A Rust application to showcase badge awards in the AT Protocol ecosystem.
1//! Badge processing logic with signature validation and image handling.
2//!
3//! Processes badge award events by validating cryptographic signatures,
4//! downloading and processing badge images, and storing validated awards.
5
6use std::str::FromStr;
7use std::sync::Arc;
8
9use crate::errors::{Result, ShowcaseError};
10use atproto_client::com::atproto::repo::{get_blob, get_record};
11use atproto_identity::{
12 key::{KeyData, identify_key, validate},
13 model::Document,
14 resolve::IdentityResolver,
15 storage::DidDocumentStorage,
16};
17use atproto_record::aturi::ATURI;
18use chrono::Utc;
19use image::{GenericImageView, ImageFormat};
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22use tracing::{error, info, warn};
23
24use crate::{
25 config::Config,
26 consumer::{AwardEvent, BadgeEventReceiver},
27 storage::{Award, Badge, FileStorage, Storage},
28};
29
30/// Badge award record structure from AT Protocol
31#[derive(Debug, Deserialize, Serialize)]
32struct AwardRecord {
33 pub did: String,
34 pub badge: StrongRef,
35 pub issued: String,
36 pub signatures: Vec<Signature>,
37}
38
39/// Strong reference to another record
40#[derive(Debug, Deserialize, Serialize)]
41struct StrongRef {
42 #[serde(rename = "$type")]
43 pub type_: String,
44 pub uri: String,
45 pub cid: String,
46}
47
48/// Signature from badge issuer
49#[derive(Debug, Deserialize, Serialize)]
50struct Signature {
51 pub issuer: String,
52 #[serde(rename = "issuedAt")]
53 pub issued_at: String,
54 pub signature: String,
55}
56
57/// Badge definition record
58#[derive(Debug, Deserialize, Serialize)]
59struct BadgeRecord {
60 pub name: String,
61 pub description: String,
62 pub image: Option<BlobRef>,
63}
64
65/// Blob reference for badge images
66#[derive(Debug, Deserialize, Serialize)]
67struct BlobRef {
68 #[serde(rename = "$type")]
69 pub type_: String,
70 #[serde(rename = "ref")]
71 pub ref_: Option<LinkRef>,
72 #[serde(rename = "mimeType")]
73 pub mime_type: String,
74 pub size: u64,
75}
76
77/// Link reference in blob
78#[derive(Debug, Deserialize, Serialize)]
79struct LinkRef {
80 #[serde(rename = "$link")]
81 pub link: String,
82}
83
84impl BlobRef {
85 fn get_ref(&self) -> Option<String> {
86 self.ref_.as_ref().map(|r| r.link.clone())
87 }
88}
89
90/// Background processor for badge events
91pub struct BadgeProcessor {
92 storage: Arc<dyn Storage>,
93 config: Arc<Config>,
94 identity_resolver: IdentityResolver,
95 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
96 http_client: reqwest::Client,
97 file_storage: Arc<dyn FileStorage>,
98}
99
100impl BadgeProcessor {
101 /// Create a new badge processor with the required dependencies.
102 pub fn new(
103 storage: Arc<dyn Storage>,
104 config: Arc<Config>,
105 identity_resolver: IdentityResolver,
106 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
107 http_client: reqwest::Client,
108 file_storage: Arc<dyn FileStorage>,
109 ) -> Self {
110 Self {
111 storage,
112 config,
113 identity_resolver,
114 document_storage,
115 http_client,
116 file_storage,
117 }
118 }
119
120 /// Start processing badge events from the queue
121 pub async fn start_processing(&self, mut event_receiver: BadgeEventReceiver) -> Result<()> {
122 info!("Badge processor started");
123
124 while let Some(event) = event_receiver.recv().await {
125 match &event {
126 AwardEvent::Commit {
127 did,
128 cid,
129 record,
130 rkey,
131 ..
132 } => {
133 if let Err(e) = self.handle_commit(did, rkey, cid, record).await {
134 error!(
135 "error-showcase-process-6 Failed to process create event: {}",
136 e
137 );
138 }
139 }
140 AwardEvent::Delete { did, rkey, .. } => {
141 if let Err(e) = self.handle_delete(did, rkey).await {
142 error!(
143 "error-showcase-process-8 Failed to process delete event: {}",
144 e
145 );
146 }
147 }
148 }
149 }
150
151 info!("Badge processor finished");
152 Ok(())
153 }
154
155 async fn handle_commit(&self, did: &str, rkey: &str, cid: &str, record: &Value) -> Result<()> {
156 info!("Processing award: {} for {}", rkey, did);
157
158 let aturi = format!("at://{did}/community.lexicon.badge.award/{rkey}");
159
160 // Parse the award record
161 let award_record: AwardRecord = serde_json::from_value(record.clone())?;
162 tracing::debug!(?award_record, "processing award");
163
164 let badge_from_issuer = self.config.badge_issuers.iter().any(|value| {
165 award_record
166 .badge
167 .uri
168 .starts_with(&format!("at://{value}/"))
169 });
170 if !badge_from_issuer {
171 return Ok(());
172 }
173
174 // Ensure identity is stored
175 let document = self.ensure_identity_stored(did).await?;
176
177 // Get or create badge
178 let badge = self.get_or_create_badge(&award_record.badge).await?;
179
180 let badge_aturi = ATURI::from_str(&award_record.badge.uri)?;
181
182 let badge_isser_document = self.ensure_identity_stored(&badge_aturi.authority).await?;
183
184 let issuer_pds_endpoint = {
185 badge_isser_document
186 .pds_endpoints()
187 .first()
188 .ok_or_else(|| ShowcaseError::ProcessIdentityResolutionFailed {
189 did: did.to_string(),
190 details: "No PDS endpoint found in DID document".to_string(),
191 })
192 .cloned()?
193 };
194
195 self.download_badge_image(issuer_pds_endpoint, &badge_isser_document.id, &badge)
196 .await?;
197
198 // Validate signatures
199 let validated_issuers = self
200 .validate_signatures(record, &document.id, "community.lexicon.badge.award")
201 .await?;
202
203 // Create award record
204 let award = Award {
205 aturi: aturi.to_string(),
206 cid: cid.to_string(),
207 did: document.id.clone(),
208 badge: award_record.badge.uri.clone(),
209 badge_cid: award_record.badge.cid.clone(),
210 badge_name: badge.name,
211 validated_issuers: serde_json::to_value(&validated_issuers)?,
212 created_at: Utc::now(),
213 updated_at: Utc::now(),
214 record: record.clone(),
215 };
216
217 // Store award
218 let is_new = self.storage.upsert_award(&award).await?;
219
220 // Update badge count if this is a new award
221 if is_new {
222 self.storage
223 .increment_badge_count(&award.badge, &award.badge_cid)
224 .await?;
225 }
226
227 // Trim awards for this DID to max 100
228 self.storage.trim_awards_for_did(did, 100).await?;
229
230 info!("Successfully processed award: {}", aturi);
231 Ok(())
232 }
233
234 async fn handle_delete(&self, did: &str, rkey: &str) -> Result<()> {
235 let aturi = format!("at://{did}/community.lexicon.badge.award/{rkey}");
236
237 if let Some(award) = self.storage.delete_award(&aturi).await? {
238 // Decrement badge count
239 self.storage
240 .decrement_badge_count(&award.badge, &award.badge_cid)
241 .await?;
242 info!("Successfully deleted award: {}", aturi);
243 }
244
245 Ok(())
246 }
247
248 async fn ensure_identity_stored(&self, did: &str) -> Result<Document> {
249 // Check if we already have this identity
250 if let Some(document) = self.document_storage.get_document_by_did(did).await? {
251 return Ok(document);
252 }
253
254 let document = self.identity_resolver.resolve(did).await?;
255 self.document_storage
256 .store_document(document.clone())
257 .await?;
258 Ok(document)
259 }
260
261 async fn get_or_create_badge(&self, badge_ref: &StrongRef) -> Result<BadgeRecord> {
262 // Check if we already have this badge
263 if let Some(existing) = self
264 .storage
265 .get_badge(&badge_ref.uri, &badge_ref.cid)
266 .await?
267 {
268 let badge_record = serde_json::from_value::<BadgeRecord>(existing.record.clone())?;
269 return Ok(badge_record);
270 }
271
272 // Parse AT-URI to get DID and record key
273 let parts: Vec<&str> = badge_ref
274 .uri
275 .strip_prefix("at://")
276 .unwrap_or(&badge_ref.uri)
277 .split('/')
278 .collect();
279 if parts.len() != 3 {
280 return Err(ShowcaseError::ProcessInvalidAturi {
281 uri: badge_ref.uri.clone(),
282 });
283 }
284
285 let repo = parts[0];
286 let rkey = parts[2];
287
288 // Get the DID document to find PDS endpoint
289 let document = self.ensure_identity_stored(repo).await?;
290 let pds_endpoints = document.pds_endpoints();
291 let pds_endpoint =
292 pds_endpoints
293 .first()
294 .ok_or_else(|| ShowcaseError::ProcessBadgeFetchFailed {
295 uri: format!("No PDS endpoint found for DID: {}", repo),
296 })?;
297
298 let badge_record = self
299 .fetch_badge_record(pds_endpoint, repo, rkey, &badge_ref.cid)
300 .await?;
301 let badge = Badge {
302 aturi: badge_ref.uri.clone(),
303 cid: badge_ref.cid.clone(),
304 name: badge_record.name.clone(),
305 image: badge_record.image.as_ref().and_then(|img| img.get_ref()),
306 created_at: Utc::now(),
307 updated_at: Utc::now(),
308 count: 0,
309 record: serde_json::to_value(&badge_record)?,
310 };
311
312 self.storage.upsert_badge(&badge).await?;
313 Ok(badge_record)
314 }
315
316 async fn fetch_badge_record(
317 &self,
318 pds: &str,
319 did: &str,
320 rkey: &str,
321 cid: &str,
322 ) -> Result<BadgeRecord> {
323 let get_record_response = get_record(
324 &self.http_client,
325 None,
326 pds,
327 did,
328 "community.lexicon.badge.definition",
329 rkey,
330 Some(cid),
331 )
332 .await?;
333
334 match get_record_response {
335 atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => {
336 serde_json::from_value(value.clone()).map_err(|e| {
337 ShowcaseError::ProcessBadgeRecordFetchFailed {
338 uri: format!("at://{}/community.lexicon.badge.definition/{}", did, rkey),
339 details: format!("Failed to deserialize record: {}", e),
340 }
341 })
342 }
343 atproto_client::com::atproto::repo::GetRecordResponse::Error(simple_error) => {
344 Err(ShowcaseError::ProcessBadgeRecordFetchFailed {
345 uri: format!("at://{}/community.lexicon.badge.definition/{}", did, rkey),
346 details: format!("Get record returned an error: {}", simple_error.error_message()),
347 })
348 }
349 }
350 }
351
352 async fn download_badge_image(&self, pds: &str, did: &str, badge: &BadgeRecord) -> Result<()> {
353 if let Some(ref image_blob) = badge.image {
354 let image_ref = match image_blob.get_ref() {
355 Some(ref_val) => ref_val,
356 None => return Ok(()), // No image reference
357 };
358
359 let image_path = format!("{}.png", image_ref);
360
361 // Check if image already exists
362 if self.file_storage.file_exists(&image_path).await? {
363 return Ok(());
364 }
365
366 // Download and process image
367 match self
368 .download_and_process_image(pds, did, &image_ref, &image_path)
369 .await
370 {
371 Ok(()) => {
372 info!("Downloaded badge image: {}", image_ref);
373 }
374 Err(e) => {
375 warn!(
376 "error-showcase-process-11 Failed to download badge image {}: {}",
377 image_ref, e
378 );
379 }
380 }
381 }
382
383 Ok(())
384 }
385
386 async fn download_and_process_image(
387 &self,
388 pds: &str,
389 did: &str,
390 blob_ref: &str,
391 output_path: &str,
392 ) -> Result<()> {
393 // Fetch the blob from PDS
394 let image_bytes = get_blob(&self.http_client, pds, did, blob_ref).await?;
395
396 // 1. Check size limit (3MB = 3 * 1024 * 1024 bytes)
397 const MAX_SIZE: usize = 3 * 1024 * 1024;
398 if image_bytes.len() > MAX_SIZE {
399 return Err(ShowcaseError::ProcessImageTooLarge {
400 size: image_bytes.len(),
401 });
402 }
403
404 // 2. Try to load and detect image format
405 let img = image::load_from_memory(&image_bytes).map_err(|e| {
406 ShowcaseError::ProcessImageDecodeFailed {
407 details: e.to_string(),
408 }
409 })?;
410
411 // Check if format is supported (JPG, PNG, WebP)
412 let format = image::guess_format(&image_bytes).map_err(|e| {
413 ShowcaseError::ProcessImageDecodeFailed {
414 details: format!("Could not detect image format: {}", e),
415 }
416 })?;
417
418 match format {
419 ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP => {
420 // Supported formats
421 }
422 _ => {
423 return Err(ShowcaseError::ProcessUnsupportedImageFormat {
424 format: format!("{:?}", format),
425 });
426 }
427 }
428
429 // 3. Check original dimensions (minimum 512x512)
430 let (original_width, original_height) = img.dimensions();
431 if original_width < 512 || original_height < 512 {
432 return Err(ShowcaseError::ProcessImageTooSmall {
433 width: original_width,
434 height: original_height,
435 });
436 }
437
438 // 4. Resize to height 512 while preserving aspect ratio
439 let aspect_ratio = original_width as f32 / original_height as f32;
440 let new_height = 512;
441 let new_width = (new_height as f32 * aspect_ratio) as u32;
442
443 // 5. Check that width after resize is >= 512
444 if new_width < 512 {
445 return Err(ShowcaseError::ProcessImageWidthTooSmall { width: new_width });
446 }
447
448 // Perform the resize
449 let mut resized_img =
450 img.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3);
451
452 // 6. Center crop the width to exactly 512 pixels if needed
453 let final_img = if new_width > 512 {
454 let crop_x = (new_width - 512) / 2;
455 resized_img.crop(crop_x, 0, 512, 512)
456 } else {
457 resized_img
458 };
459
460 // Save as PNG to byte buffer
461 let mut png_buffer = std::io::Cursor::new(Vec::new());
462 final_img.write_to(&mut png_buffer, ImageFormat::Png)?;
463 let png_bytes = png_buffer.into_inner();
464
465 // Write the processed image using file storage
466 self.file_storage
467 .write_file(output_path, &png_bytes)
468 .await?;
469
470 info!(
471 "Processed badge image {}: {}x{} -> 512x512",
472 blob_ref, original_width, original_height
473 );
474 Ok(())
475 }
476
477 async fn validate_signatures(
478 &self,
479 record: &serde_json::Value,
480 repository: &str,
481 collection: &str,
482 ) -> Result<Vec<String>> {
483 let mut validated_issuers = Vec::new();
484
485 // Extract signatures from the record
486 let signatures = record
487 .get("signatures")
488 .and_then(|v| v.as_array())
489 .ok_or(ShowcaseError::ProcessNoSignaturesField)?;
490
491 for sig_obj in signatures {
492 // Extract the issuer from the signature object
493 let signature_issuer = sig_obj
494 .get("issuer")
495 .and_then(|v| v.as_str())
496 .ok_or(ShowcaseError::ProcessMissingIssuerField)?;
497
498 // Retrieve the DID document for the issuer
499 let did_document = match self
500 .document_storage
501 .get_document_by_did(signature_issuer)
502 .await
503 {
504 Ok(Some(doc)) => doc,
505 Ok(None) => {
506 warn!(
507 "error-showcase-process-16 Failed to retrieve DID document for issuer {}: not found",
508 signature_issuer
509 );
510 continue;
511 }
512 Err(e) => {
513 warn!(
514 "error-showcase-process-16 Failed to retrieve DID document for issuer {}: {}",
515 signature_issuer, e
516 );
517 continue;
518 }
519 };
520
521 // Iterate over all keys available in the DID document
522 for key_string in did_document.did_keys() {
523 // Decode the key using identify_key
524 let key_data = match identify_key(key_string) {
525 Ok(key_data) => key_data,
526 Err(e) => {
527 warn!(
528 "error-showcase-process-17 Failed to decode key {} for issuer {}: {}",
529 key_string, signature_issuer, e
530 );
531 continue;
532 }
533 };
534
535 // Attempt to verify the signature with this key
536 match self
537 .verify_signature(
538 signature_issuer,
539 &key_data,
540 record,
541 repository,
542 collection,
543 sig_obj,
544 )
545 .await
546 {
547 Ok(()) => {
548 validated_issuers.push(signature_issuer.to_string());
549 info!(
550 "Validated signature from trusted issuer {} using key: {}",
551 signature_issuer, key_string
552 );
553 break; // Stop trying other keys once we find one that works
554 }
555 Err(_) => {
556 // Continue trying other keys - don't warn on each failure as this is expected
557 continue;
558 }
559 }
560 }
561 }
562
563 Ok(validated_issuers)
564 }
565
566 async fn verify_signature(
567 &self,
568 issuer: &str,
569 key_data: &KeyData,
570 record: &serde_json::Value,
571 repository: &str,
572 collection: &str,
573 sig_obj: &serde_json::Value,
574 ) -> Result<()> {
575 // Reconstruct the $sig object as per the reference implementation
576 let mut sig_variable = sig_obj.clone();
577
578 if let Some(sig_map) = sig_variable.as_object_mut() {
579 sig_map.remove("signature");
580 sig_map.insert("repository".to_string(), json!(repository));
581 sig_map.insert("collection".to_string(), json!(collection));
582 }
583
584 // Create the signed record for verification
585 let mut signed_record = record.clone();
586 if let Some(record_map) = signed_record.as_object_mut() {
587 record_map.remove("signatures");
588 record_map.insert("$sig".to_string(), sig_variable);
589 }
590
591 // Serialize the record using IPLD DAG-CBOR
592 let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record).map_err(|e| {
593 ShowcaseError::ProcessRecordSerializationFailed {
594 details: e.to_string(),
595 }
596 })?;
597
598 // Get the signature value and decode it
599 let signature_value = sig_obj
600 .get("signature")
601 .and_then(|v| v.as_str())
602 .ok_or(ShowcaseError::ProcessMissingSignatureField)?;
603
604 let (_, signature_bytes) = multibase::decode(signature_value).map_err(|e| {
605 ShowcaseError::ProcessSignatureDecodingFailed {
606 details: e.to_string(),
607 }
608 })?;
609
610 // Validate the signature
611 validate(key_data, &signature_bytes, &serialized_record).map_err(|e| {
612 ShowcaseError::ProcessCryptographicValidationFailed {
613 issuer: issuer.to_string(),
614 details: e.to_string(),
615 }
616 })?;
617
618 Ok(())
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 #[cfg(feature = "sqlite")]
626 use crate::storage::{LocalFileStorage, SqliteStorage, SqliteStorageDidDocumentStorage};
627 use atproto_identity::model::Document;
628 use serde_json::json;
629 use std::collections::HashMap;
630
631 #[cfg(feature = "sqlite")]
632 #[tokio::test]
633 async fn test_validate_signatures_no_signatures_field() {
634 let config = Arc::new(Config::default());
635 let storage = Arc::new(SqliteStorage::new(
636 sqlx::SqlitePool::connect(":memory:").await.unwrap(),
637 ));
638 let identity_resolver = create_mock_identity_resolver();
639 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone()));
640 let http_client = reqwest::Client::new();
641
642 let processor = BadgeProcessor::new(
643 storage as Arc<dyn Storage>,
644 config,
645 identity_resolver,
646 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>,
647 http_client,
648 create_test_file_storage(),
649 );
650
651 // Record without signatures field
652 let record = json!({
653 "did": "did:plc:test",
654 "badge": {
655 "$type": "strongRef",
656 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test",
657 "cid": "bafyreiabc123"
658 },
659 "issued": "2023-01-01T00:00:00Z"
660 });
661
662 let result = processor
663 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award")
664 .await;
665
666 // Should return error because no signatures field
667 assert!(result.is_err());
668 if let Err(ShowcaseError::ProcessNoSignaturesField) = result {
669 // Expected error
670 } else {
671 panic!("Expected ProcessNoSignaturesField error");
672 }
673 }
674
675 #[cfg(feature = "sqlite")]
676 #[tokio::test]
677 async fn test_validate_signatures_untrusted_issuer() {
678 let mut config = Config::default();
679 config.badge_issuers = vec!["did:plc:trusted".to_string()];
680 let config = Arc::new(config);
681
682 let storage = Arc::new(SqliteStorage::new(
683 sqlx::SqlitePool::connect(":memory:").await.unwrap(),
684 ));
685 let identity_resolver = create_mock_identity_resolver();
686 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone()));
687 let http_client = reqwest::Client::new();
688
689 let processor = BadgeProcessor::new(
690 storage as Arc<dyn Storage>,
691 config,
692 identity_resolver,
693 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>,
694 http_client,
695 create_test_file_storage(),
696 );
697
698 // Record with signature from untrusted issuer
699 let record = json!({
700 "did": "did:plc:test",
701 "badge": {
702 "$type": "strongRef",
703 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test",
704 "cid": "bafyreiabc123"
705 },
706 "issued": "2023-01-01T00:00:00Z",
707 "signatures": [{
708 "issuer": "did:plc:untrusted",
709 "issuedAt": "2023-01-01T00:00:00Z",
710 "signature": "mEiDqZ4..."
711 }]
712 });
713
714 let result = processor
715 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award")
716 .await;
717
718 // Should succeed but return empty list (untrusted issuer ignored)
719 assert!(result.is_ok());
720 assert_eq!(result.unwrap().len(), 0);
721 }
722
723 #[cfg(feature = "sqlite")]
724 #[tokio::test]
725 async fn test_validate_signatures_no_did_keys() {
726 let mut config = Config::default();
727 config.badge_issuers = vec!["did:plc:trusted".to_string()];
728 let config = Arc::new(config);
729
730 let storage = Arc::new(SqliteStorage::new(
731 sqlx::SqlitePool::connect(":memory:").await.unwrap(),
732 ));
733 storage.migrate().await.unwrap(); // Initialize database tables
734
735 let identity_resolver = create_mock_identity_resolver();
736 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone()));
737 let http_client = reqwest::Client::new();
738
739 // Create a mock DID document with no keys (using a Document that has an empty did_keys() result)
740 let document = Document {
741 id: "did:plc:trusted".to_string(),
742 also_known_as: vec![],
743 service: vec![],
744 verification_method: vec![], // Empty verification methods means no keys
745 extra: HashMap::new(),
746 };
747
748 // Store the document in our storage
749 document_storage.store_document(document).await.unwrap();
750
751 let processor = BadgeProcessor::new(
752 storage as Arc<dyn Storage>,
753 config,
754 identity_resolver,
755 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>,
756 http_client,
757 create_test_file_storage(),
758 );
759
760 // Record with signature from trusted issuer but no valid keys in DID document
761 let record = json!({
762 "did": "did:plc:test",
763 "badge": {
764 "$type": "strongRef",
765 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test",
766 "cid": "bafyreiabc123"
767 },
768 "issued": "2023-01-01T00:00:00Z",
769 "signatures": [{
770 "issuer": "did:plc:trusted",
771 "issuedAt": "2023-01-01T00:00:00Z",
772 "signature": "mEiDqZ4..."
773 }]
774 });
775
776 let result = processor
777 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award")
778 .await;
779
780 // Should succeed but return empty list (no valid keys to verify with)
781 assert!(result.is_ok());
782 assert_eq!(result.unwrap().len(), 0);
783 }
784
785 #[cfg(feature = "sqlite")]
786 #[tokio::test]
787 async fn test_image_validation_too_large() {
788 let config = Arc::new(Config::default());
789 let storage = Arc::new(SqliteStorage::new(
790 sqlx::SqlitePool::connect(":memory:").await.unwrap(),
791 ));
792 let identity_resolver = create_mock_identity_resolver();
793 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone()));
794 let http_client = reqwest::Client::new();
795
796 let _processor = BadgeProcessor::new(
797 storage,
798 config,
799 identity_resolver,
800 document_storage,
801 http_client,
802 create_test_file_storage(),
803 );
804
805 // Create test image bytes larger than 3MB
806 let large_image_bytes = vec![0u8; 4 * 1024 * 1024]; // 4MB
807
808 // We can't easily test this without mocking get_blob, but we can test the size check logic directly
809 assert!(large_image_bytes.len() > 3 * 1024 * 1024);
810 }
811
812 #[tokio::test]
813 async fn test_image_validation_unsupported_format() {
814 // This would require creating actual image bytes of unsupported format
815 // For now, we can verify the error types exist and compile correctly
816 let error = ShowcaseError::ProcessUnsupportedImageFormat {
817 format: "BMP".to_string(),
818 };
819 assert!(error.to_string().contains("Unsupported image format"));
820 }
821
822 #[tokio::test]
823 async fn test_image_validation_too_small() {
824 let error = ShowcaseError::ProcessImageTooSmall {
825 width: 256,
826 height: 256,
827 };
828 assert!(error.to_string().contains("256x256"));
829 assert!(error.to_string().contains("minimum is 512x512"));
830 }
831
832 #[tokio::test]
833 async fn test_image_validation_width_too_small_after_resize() {
834 let error = ShowcaseError::ProcessImageWidthTooSmall { width: 300 };
835 assert!(error.to_string().contains("300"));
836 assert!(error.to_string().contains("minimum is 512"));
837 }
838
839 #[tokio::test]
840 async fn test_center_crop_logic() {
841 // Test the center crop calculation logic
842 // If we have an image that's 1024x512 after resize, it should be center cropped to 512x512
843 let original_width = 1024u32;
844 let target_width = 512u32;
845
846 // This is the same calculation used in download_and_process_image
847 let crop_x = (original_width - target_width) / 2;
848
849 // Should crop from x=256 to get the center 512 pixels
850 assert_eq!(crop_x, 256);
851
852 // Test with different widths
853 let wide_width = 768u32;
854 let crop_x_wide = (wide_width - target_width) / 2;
855 assert_eq!(crop_x_wide, 128); // Should crop 128 pixels from each side
856 }
857
858 fn create_mock_identity_resolver() -> IdentityResolver {
859 // Create a mock resolver - in a real test, you'd want to properly mock this
860 // For now, we'll create one with default DNS resolver and HTTP client
861 use atproto_identity::resolve::{InnerIdentityResolver, create_resolver};
862
863 let dns_resolver = create_resolver(&[]);
864 let http_client = reqwest::Client::new();
865
866 IdentityResolver(Arc::new(InnerIdentityResolver {
867 dns_resolver,
868 http_client,
869 plc_hostname: "plc.directory".to_string(),
870 }))
871 }
872
873 #[cfg(feature = "sqlite")]
874 fn create_test_file_storage() -> Arc<dyn FileStorage> {
875 // Create a temporary directory for test file storage
876 // Note: In a real test, you'd want to ensure this gets cleaned up properly
877 // For simplicity, we'll use a simple path that gets cleaned up by the OS
878 let temp_dir = "/tmp/showcase_test_storage";
879 Arc::new(LocalFileStorage::new(temp_dir.to_string()))
880 }
881}