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 }