lib

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

file_metadata.rs (13586B)


      1 #![cfg(feature = "json")]
      2 
      3 mod common;
      4 
      5 use common::{AUTHOR, EVENT_ID, EVENT_SIG};
      6 use radroots_event::{
      7     envelope::kind::{KIND_POST, KIND_PUBLIC_FILE_METADATA},
      8     farm::crdt::FarmCrdtDocumentKind,
      9     farm::file::{FarmFileDimensions, FarmFileMetadata, FarmFileSource},
     10     farm::workspace::FarmWorkspaceRef,
     11     media::file_metadata::FileMetadata,
     12     social::{SocialMediaDimensions, SocialMediaThumbnail},
     13     tag::name::{
     14         TAG_ALT, TAG_DIMENSIONS, TAG_FALLBACK, TAG_MAGNET, TAG_MIME, TAG_ORIGINAL_SHA256,
     15         TAG_SERVICE, TAG_SHA256, TAG_SIZE, TAG_SUMMARY, TAG_THUMB, TAG_URL,
     16     },
     17 };
     18 use radroots_event_codec::decode::{
     19     EventParseError,
     20     farm_file::farm_file_metadata_from_event,
     21     file_metadata::{data_from_event, file_metadata_from_event, parsed_from_event},
     22 };
     23 use radroots_event_codec::encode::{
     24     EventEncodeError,
     25     farm_file::to_wire_parts as farm_file_to_wire_parts,
     26     file_metadata::{file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind},
     27 };
     28 
     29 const VALID_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     30 const OTHER_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
     31 
     32 fn sample_metadata() -> FileMetadata {
     33     FileMetadata {
     34         url: "https://media.example.test/field.jpg".to_string(),
     35         mime_type: "image/jpeg".to_string(),
     36         sha256: VALID_HASH.to_string(),
     37         original_sha256: Some(OTHER_HASH.to_string()),
     38         size: Some(4096),
     39         dimensions: Some(SocialMediaDimensions {
     40             width: 1200,
     41             height: 800,
     42         }),
     43         blurhash: Some("L6PZfSi_.AyE_3t7t7R**0o#DgR4".to_string()),
     44         thumbnails: Some(vec![SocialMediaThumbnail {
     45             url: "https://media.example.test/field-thumb.jpg".to_string(),
     46             dimensions: Some(SocialMediaDimensions {
     47                 width: 320,
     48                 height: 200,
     49             }),
     50         }]),
     51         summary: Some("Field image".to_string()),
     52         alt: Some("Rows of greens after harvest".to_string()),
     53         fallback: Some("https://backup.example.test/field.jpg".to_string()),
     54         magnet: Some("magnet:?xt=urn:btih:example".to_string()),
     55         content_hashes: Some(vec![format!("sha256:{VALID_HASH}")]),
     56         services: Some(vec!["https://media.example.test".to_string()]),
     57         content: Some("Harvest block photo".to_string()),
     58     }
     59 }
     60 
     61 fn sample_farm_file_metadata() -> FarmFileMetadata {
     62     FarmFileMetadata {
     63         d_tag: "BBBBBBBBBBBBBBBBBBBBBA".to_string(),
     64         workspace: FarmWorkspaceRef {
     65             pubkey: "workspace_pubkey".to_string(),
     66             d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
     67         },
     68         farm_group_id: "field-group".to_string(),
     69         owner_document_id: "CCCCCCCCCCCCCCCCCCCCCA".to_string(),
     70         owner_document_kind: FarmCrdtDocumentKind::FarmTask,
     71         caption: Some("Private crop photo".to_string()),
     72         url: "https://media.example.test/private.jpg".to_string(),
     73         mime_type: "image/jpeg".to_string(),
     74         sha256: VALID_HASH.to_string(),
     75         original_sha256: None,
     76         size_bytes: Some(2048),
     77         dimensions: Some(FarmFileDimensions { w: 800, h: 600 }),
     78         blurhash: None,
     79         thumb: None,
     80         image: None,
     81         alt: Some("Private rows".to_string()),
     82         fallbacks: Vec::new(),
     83     }
     84 }
     85 
     86 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
     87     tags.iter().any(|tag| {
     88         tag.first().map(|entry| entry.as_str()) == Some(key)
     89             && tag.get(1).map(|entry| entry.as_str()) == Some(value)
     90     })
     91 }
     92 
     93 #[test]
     94 fn file_metadata_to_wire_parts_roundtrips_nip94_tags() {
     95     let metadata = sample_metadata();
     96     let parts = to_wire_parts(&metadata).unwrap();
     97 
     98     assert_eq!(parts.kind, KIND_PUBLIC_FILE_METADATA);
     99     assert_eq!(parts.content, "Harvest block photo");
    100     assert!(has_tag(
    101         &parts.tags,
    102         TAG_URL,
    103         "https://media.example.test/field.jpg"
    104     ));
    105     assert!(has_tag(&parts.tags, TAG_MIME, "image/jpeg"));
    106     assert!(has_tag(&parts.tags, TAG_SHA256, VALID_HASH));
    107     assert!(has_tag(&parts.tags, TAG_ORIGINAL_SHA256, OTHER_HASH));
    108     assert!(has_tag(&parts.tags, TAG_SIZE, "4096"));
    109     assert!(has_tag(&parts.tags, TAG_DIMENSIONS, "1200x800"));
    110     assert!(has_tag(
    111         &parts.tags,
    112         TAG_THUMB,
    113         "https://media.example.test/field-thumb.jpg"
    114     ));
    115     assert!(has_tag(&parts.tags, TAG_SUMMARY, "Field image"));
    116     assert!(has_tag(
    117         &parts.tags,
    118         TAG_ALT,
    119         "Rows of greens after harvest"
    120     ));
    121     assert!(has_tag(
    122         &parts.tags,
    123         TAG_FALLBACK,
    124         "https://backup.example.test/field.jpg"
    125     ));
    126     assert!(has_tag(
    127         &parts.tags,
    128         TAG_MAGNET,
    129         "magnet:?xt=urn:btih:example"
    130     ));
    131     assert!(has_tag(
    132         &parts.tags,
    133         "i",
    134         format!("sha256:{VALID_HASH}").as_str()
    135     ));
    136     assert!(has_tag(
    137         &parts.tags,
    138         TAG_SERVICE,
    139         "https://media.example.test"
    140     ));
    141 
    142     let decoded = file_metadata_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    143     assert_eq!(decoded.url, "https://media.example.test/field.jpg");
    144     assert_eq!(decoded.mime_type, "image/jpeg");
    145     assert_eq!(decoded.sha256, VALID_HASH);
    146     assert_eq!(decoded.original_sha256.as_deref(), Some(OTHER_HASH));
    147     assert_eq!(decoded.size, Some(4096));
    148     assert_eq!(decoded.dimensions.as_ref().map(|dim| dim.width), Some(1200));
    149     assert_eq!(
    150         decoded
    151             .thumbnails
    152             .as_ref()
    153             .map(|thumbnails| thumbnails[0].url.as_str()),
    154         Some("https://media.example.test/field-thumb.jpg")
    155     );
    156     assert_eq!(decoded.content.as_deref(), Some("Harvest block photo"));
    157 }
    158 
    159 #[test]
    160 fn file_metadata_encode_handles_minimal_optional_shape_and_invalid_dimensions() {
    161     let mut metadata = sample_metadata();
    162     metadata.original_sha256 = None;
    163     metadata.size = None;
    164     metadata.dimensions = None;
    165     metadata.thumbnails = None;
    166     metadata.summary = None;
    167     metadata.alt = None;
    168     metadata.fallback = None;
    169     metadata.magnet = None;
    170     metadata.content_hashes = None;
    171     metadata.services = None;
    172     metadata.content = None;
    173 
    174     let parts = to_wire_parts(&metadata).unwrap();
    175     assert_eq!(parts.content, "");
    176     assert!(
    177         !parts
    178             .tags
    179             .iter()
    180             .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SIZE))
    181     );
    182     assert!(
    183         !parts
    184             .tags
    185             .iter()
    186             .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_DIMENSIONS))
    187     );
    188     assert!(
    189         !parts
    190             .tags
    191             .iter()
    192             .any(|tag| { tag.first().map(|value| value.as_str()) == Some(TAG_ORIGINAL_SHA256) })
    193     );
    194 
    195     let mut metadata = sample_metadata();
    196     metadata.dimensions = Some(SocialMediaDimensions {
    197         width: 0,
    198         height: 800,
    199     });
    200     assert!(matches!(
    201         file_metadata_build_tags(&metadata),
    202         Err(EventEncodeError::InvalidField("dimensions"))
    203     ));
    204 
    205     let mut metadata = sample_metadata();
    206     metadata.dimensions = Some(SocialMediaDimensions {
    207         width: 1200,
    208         height: 0,
    209     });
    210     assert!(matches!(
    211         file_metadata_build_tags(&metadata),
    212         Err(EventEncodeError::InvalidField("dimensions"))
    213     ));
    214 }
    215 
    216 #[test]
    217 fn file_metadata_public_and_private_kind1063_contracts_do_not_cross_decode() {
    218     let public = to_wire_parts(&sample_metadata()).unwrap();
    219     let decoded_public =
    220         file_metadata_from_event(public.kind, &public.tags, &public.content).unwrap();
    221     assert_eq!(decoded_public.url, "https://media.example.test/field.jpg");
    222     assert!(matches!(
    223         farm_file_metadata_from_event(public.kind, &public.tags, &public.content),
    224         Err(EventParseError::MissingTag("d"))
    225     ));
    226 
    227     let mut private_metadata = sample_farm_file_metadata();
    228     private_metadata.thumb = Some(FarmFileSource {
    229         url: "https://media.example.test/private-thumb.jpg".to_string(),
    230         mime_type: Some("image/jpeg".to_string()),
    231         dimensions: Some(FarmFileDimensions { w: 320, h: 240 }),
    232     });
    233     private_metadata.image = Some(FarmFileSource {
    234         url: "https://media.example.test/private-image.jpg".to_string(),
    235         mime_type: Some("image/jpeg".to_string()),
    236         dimensions: None,
    237     });
    238     let private = farm_file_to_wire_parts(&private_metadata).unwrap();
    239     let decoded_private =
    240         farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap();
    241     assert_eq!(decoded_private.owner_document_id, "CCCCCCCCCCCCCCCCCCCCCA");
    242     assert_eq!(
    243         decoded_private.thumb.and_then(|source| source.dimensions),
    244         Some(FarmFileDimensions { w: 320, h: 240 })
    245     );
    246     assert_eq!(
    247         decoded_private
    248             .image
    249             .map(|source| (source.url, source.dimensions)),
    250         Some((
    251             "https://media.example.test/private-image.jpg".to_string(),
    252             None
    253         ))
    254     );
    255     assert!(matches!(
    256         file_metadata_from_event(private.kind, &private.tags, &private.content),
    257         Err(EventParseError::InvalidTag("radroots:owner_document"))
    258     ));
    259 }
    260 
    261 #[test]
    262 fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() {
    263     let mut metadata = sample_metadata();
    264     metadata.url = "ipfs://field.jpg".to_string();
    265     assert!(matches!(
    266         file_metadata_build_tags(&metadata),
    267         Err(EventEncodeError::InvalidField("url"))
    268     ));
    269 
    270     let mut metadata = sample_metadata();
    271     metadata.sha256 = "ABC".to_string();
    272     assert!(matches!(
    273         to_wire_parts(&metadata),
    274         Err(EventEncodeError::InvalidField("sha256"))
    275     ));
    276 
    277     assert!(matches!(
    278         to_wire_parts_with_kind(&sample_metadata(), KIND_POST),
    279         Err(EventEncodeError::InvalidKind(KIND_POST))
    280     ));
    281     assert!(matches!(
    282         file_metadata_from_event(KIND_POST, &[], ""),
    283         Err(EventParseError::InvalidKind {
    284             expected: "1063",
    285             got: KIND_POST
    286         })
    287     ));
    288 
    289     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    290     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_URL));
    291     assert!(matches!(
    292         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    293         Err(EventParseError::MissingTag(TAG_URL))
    294     ));
    295 
    296     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    297     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_MIME));
    298     assert!(matches!(
    299         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    300         Err(EventParseError::MissingTag(TAG_MIME))
    301     ));
    302 
    303     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    304     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_SHA256));
    305     assert!(matches!(
    306         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    307         Err(EventParseError::MissingTag(TAG_SHA256))
    308     ));
    309 
    310     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    311     let hash_tag = tags
    312         .iter_mut()
    313         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SHA256))
    314         .expect("x tag");
    315     hash_tag[1] = "not-a-hash".to_string();
    316     assert!(matches!(
    317         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    318         Err(EventParseError::InvalidTag(TAG_SHA256))
    319     ));
    320 
    321     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    322     let size_tag = tags
    323         .iter_mut()
    324         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SIZE))
    325         .expect("size tag");
    326     size_tag[1] = "not-a-number".to_string();
    327     assert!(matches!(
    328         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    329         Err(EventParseError::InvalidNumber(TAG_SIZE, _))
    330     ));
    331 }
    332 
    333 #[test]
    334 fn file_metadata_decode_handles_minimal_public_tags() {
    335     let tags = vec![
    336         vec![
    337             TAG_URL.to_string(),
    338             "https://media.example.test/min.jpg".to_string(),
    339         ],
    340         vec![TAG_MIME.to_string(), "image/jpeg".to_string()],
    341         vec![TAG_SHA256.to_string(), VALID_HASH.to_string()],
    342     ];
    343     let decoded = file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, "").unwrap();
    344 
    345     assert_eq!(decoded.url, "https://media.example.test/min.jpg");
    346     assert_eq!(decoded.mime_type, "image/jpeg");
    347     assert_eq!(decoded.sha256, VALID_HASH);
    348     assert_eq!(decoded.original_sha256, None);
    349     assert_eq!(decoded.size, None);
    350     assert_eq!(decoded.dimensions, None);
    351     assert_eq!(decoded.thumbnails, None);
    352     assert_eq!(decoded.content_hashes, None);
    353     assert_eq!(decoded.services, None);
    354     assert_eq!(decoded.content, None);
    355 }
    356 
    357 #[test]
    358 fn file_metadata_wrappers_preserve_event_metadata() {
    359     let metadata = sample_metadata();
    360     let parts = to_wire_parts(&metadata).unwrap();
    361     let data = data_from_event(
    362         "file_id".to_string(),
    363         "author".to_string(),
    364         90,
    365         parts.kind,
    366         parts.content.clone(),
    367         parts.tags.clone(),
    368     )
    369     .unwrap();
    370 
    371     assert_eq!(data.id, "file_id");
    372     assert_eq!(data.kind, KIND_PUBLIC_FILE_METADATA);
    373     assert_eq!(data.data.url, "https://media.example.test/field.jpg");
    374 
    375     let parsed = parsed_from_event(
    376         EVENT_ID.to_string(),
    377         AUTHOR.to_string(),
    378         90,
    379         parts.kind,
    380         parts.content,
    381         parts.tags,
    382         EVENT_SIG.to_string(),
    383     )
    384     .unwrap();
    385 
    386     assert_eq!(parsed.event.created_at_u64(), 90);
    387     assert_eq!(parsed.event.signature_hex(), EVENT_SIG);
    388     assert_eq!(parsed.data.data.sha256, VALID_HASH);
    389 }