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