Skip to content

Commit 3858d89

Browse files
authored
Merge pull request #11519 from Turbo87/og-image-tests
og_image: Use mockito to test avatar downloading code
2 parents 2afca07 + 4db9d23 commit 3858d89

File tree

8 files changed

+160
-76
lines changed

8 files changed

+160
-76
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/crates_io_og_image/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ workspace = true
1010

1111
[dependencies]
1212
anyhow = "=1.0.98"
13-
bytes = "=1.10.1"
1413
crates_io_env_vars = { path = "../crates_io_env_vars" }
1514
reqwest = "=0.12.22"
1615
serde = { version = "=1.0.219", features = ["derive"] }
@@ -22,5 +21,6 @@ tracing = "=0.1.41"
2221

2322
[dev-dependencies]
2423
insta = "=1.43.1"
24+
mockito = "=1.7.0"
2525
tokio = { version = "=1.46.1", features = ["macros", "rt-multi-thread"] }
2626
tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "fmt"] }

crates/crates_io_og_image/src/lib.rs

Lines changed: 152 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ mod formatting;
66
pub use error::OgImageError;
77

88
use crate::formatting::{serialize_bytes, serialize_number, serialize_optional_number};
9-
use bytes::Bytes;
109
use crates_io_env_vars::var;
1110
use reqwest::StatusCode;
1211
use serde::Serialize;
12+
use std::borrow::Cow;
1313
use std::collections::HashMap;
1414
use std::path::{Path, PathBuf};
1515
use tempfile::NamedTempFile;
@@ -49,24 +49,19 @@ pub struct OgImageData<'a> {
4949
pub struct OgImageAuthorData<'a> {
5050
/// Author username/name
5151
pub name: &'a str,
52-
/// Optional avatar - either "test-avatar" for the test avatar or a URL
53-
pub avatar: Option<&'a str>,
52+
/// Optional avatar URL
53+
pub avatar: Option<Cow<'a, str>>,
5454
}
5555

5656
impl<'a> OgImageAuthorData<'a> {
5757
/// Creates a new `OgImageAuthorData` with the specified name and optional avatar.
58-
pub const fn new(name: &'a str, avatar: Option<&'a str>) -> Self {
58+
pub const fn new(name: &'a str, avatar: Option<Cow<'a, str>>) -> Self {
5959
Self { name, avatar }
6060
}
6161

6262
/// Creates a new `OgImageAuthorData` with a URL-based avatar.
63-
pub fn with_url(name: &'a str, url: &'a str) -> Self {
64-
Self::new(name, Some(url))
65-
}
66-
67-
/// Creates a new `OgImageAuthorData` with the test avatar.
68-
pub fn with_test_avatar(name: &'a str) -> Self {
69-
Self::with_url(name, "test-avatar")
63+
pub fn with_url(name: &'a str, url: impl Into<Cow<'a, str>>) -> Self {
64+
Self::new(name, Some(url.into()))
7065
}
7166
}
7267

@@ -245,54 +240,46 @@ impl OgImageGenerator {
245240
"Processing avatar for author {}", author.name
246241
);
247242

248-
// Get the bytes either from the included asset or download from URL
249-
let bytes = if *avatar == "test-avatar" {
250-
debug!("Using bundled test avatar");
251-
// Copy directly from included bytes
252-
Bytes::from_static(include_bytes!("../template/assets/test-avatar.png"))
253-
} else {
254-
debug!(url = %avatar, "Downloading avatar from URL: {avatar}");
255-
// Download the avatar from the URL
256-
let response = client.get(*avatar).send().await.map_err(|err| {
257-
OgImageError::AvatarDownloadError {
258-
url: avatar.to_string(),
259-
source: err,
260-
}
261-
})?;
262-
263-
let status = response.status();
264-
if status == StatusCode::NOT_FOUND {
265-
warn!(url = %avatar, "Avatar URL returned 404 Not Found");
266-
continue; // Skip this avatar if not found
243+
// Download the avatar from the URL
244+
debug!(url = %avatar, "Downloading avatar from URL: {avatar}");
245+
let response = client.get(avatar.as_ref()).send().await.map_err(|err| {
246+
OgImageError::AvatarDownloadError {
247+
url: avatar.to_string(),
248+
source: err,
267249
}
250+
})?;
251+
252+
let status = response.status();
253+
if status == StatusCode::NOT_FOUND {
254+
warn!(url = %avatar, "Avatar URL returned 404 Not Found");
255+
continue; // Skip this avatar if not found
256+
}
257+
258+
if let Err(err) = response.error_for_status_ref() {
259+
return Err(OgImageError::AvatarDownloadError {
260+
url: avatar.to_string(),
261+
source: err,
262+
});
263+
}
264+
265+
let content_length = response.content_length();
266+
debug!(
267+
url = %avatar,
268+
content_length = ?content_length,
269+
status = %response.status(),
270+
"Avatar download response received"
271+
);
268272

269-
if let Err(err) = response.error_for_status_ref() {
270-
return Err(OgImageError::AvatarDownloadError {
271-
url: avatar.to_string(),
272-
source: err,
273-
});
273+
let bytes = response.bytes().await;
274+
let bytes = bytes.map_err(|err| {
275+
error!(url = %avatar, error = %err, "Failed to read avatar response bytes");
276+
OgImageError::AvatarDownloadError {
277+
url: (*avatar).to_string(),
278+
source: err,
274279
}
280+
})?;
275281

276-
let content_length = response.content_length();
277-
debug!(
278-
url = %avatar,
279-
content_length = ?content_length,
280-
status = %response.status(),
281-
"Avatar download response received"
282-
);
283-
284-
let bytes = response.bytes().await;
285-
let bytes = bytes.map_err(|err| {
286-
error!(url = %avatar, error = %err, "Failed to read avatar response bytes");
287-
OgImageError::AvatarDownloadError {
288-
url: (*avatar).to_string(),
289-
source: err,
290-
}
291-
})?;
292-
293-
debug!(url = %avatar, size_bytes = bytes.len(), "Avatar downloaded successfully");
294-
bytes
295-
};
282+
debug!(url = %avatar, size_bytes = bytes.len(), "Avatar downloaded successfully");
296283

297284
// Detect the image format and determine the appropriate file extension
298285
let Some(extension) = Self::detect_image_format(&bytes) else {
@@ -336,7 +323,7 @@ impl OgImageGenerator {
336323
);
337324

338325
// Store the mapping from the avatar source to the numbered filename
339-
avatar_map.insert(*avatar, filename);
326+
avatar_map.insert(avatar.as_ref(), filename);
340327
}
341328
}
342329

@@ -597,6 +584,7 @@ impl Default for OgImageGenerator {
597584
#[cfg(test)]
598585
mod tests {
599586
use super::*;
587+
use mockito::{Server, ServerGuard};
600588
use tracing::dispatcher::DefaultGuard;
601589
use tracing::{Level, subscriber};
602590
use tracing_subscriber::fmt;
@@ -611,12 +599,50 @@ mod tests {
611599
subscriber::set_default(subscriber)
612600
}
613601

602+
async fn create_mock_avatar_server() -> ServerGuard {
603+
let mut server = Server::new_async().await;
604+
605+
// Mock for successful PNG avatar download
606+
server
607+
.mock("GET", "/test-avatar.png")
608+
.with_status(200)
609+
.with_header("content-type", "image/png")
610+
.with_body(include_bytes!("../template/assets/test-avatar.png"))
611+
.create();
612+
613+
// Mock for JPEG avatar download
614+
server
615+
.mock("GET", "/test-avatar.jpg")
616+
.with_status(200)
617+
.with_header("content-type", "image/jpeg")
618+
.with_body(include_bytes!("../template/assets/test-avatar.jpg"))
619+
.create();
620+
621+
// Mock for unsupported file type (WebP)
622+
server
623+
.mock("GET", "/test-avatar.webp")
624+
.with_status(200)
625+
.with_header("content-type", "image/webp")
626+
.with_body(include_bytes!("../template/assets/test-avatar.webp"))
627+
.create();
628+
629+
// Mock for 404 avatar download
630+
server
631+
.mock("GET", "/missing-avatar.png")
632+
.with_status(404)
633+
.with_header("content-type", "text/plain")
634+
.with_body("Not Found")
635+
.create();
636+
637+
server
638+
}
639+
614640
const fn author(name: &str) -> OgImageAuthorData<'_> {
615641
OgImageAuthorData::new(name, None)
616642
}
617643

618-
const fn author_with_avatar(name: &str) -> OgImageAuthorData<'_> {
619-
OgImageAuthorData::new(name, Some("test-avatar"))
644+
fn author_with_avatar(name: &str, url: String) -> OgImageAuthorData<'_> {
645+
OgImageAuthorData::with_url(name, url)
620646
}
621647

622648
fn create_minimal_test_data() -> OgImageData<'static> {
@@ -635,13 +661,18 @@ mod tests {
635661
}
636662
}
637663

638-
fn create_escaping_test_data() -> OgImageData<'static> {
639-
static AUTHORS: &[OgImageAuthorData<'_>] = &[
640-
author_with_avatar("author \"with quotes\""),
664+
fn create_escaping_authors(server_url: &str) -> Vec<OgImageAuthorData<'_>> {
665+
vec![
666+
author_with_avatar(
667+
"author \"with quotes\"",
668+
format!("{server_url}/test-avatar.png"),
669+
),
641670
author("author\\with\\backslashes"),
642671
author("author#with#hashes"),
643-
];
672+
]
673+
}
644674

675+
fn create_escaping_test_data<'a>(authors: &'a [OgImageAuthorData<'a>]) -> OgImageData<'a> {
645676
OgImageData {
646677
name: "crate-with-\"quotes\"",
647678
version: "1.0.0-\"beta\"",
@@ -654,27 +685,35 @@ mod tests {
654685
"tag\\with\\backslashes",
655686
"tag#with#symbols",
656687
],
657-
authors: AUTHORS,
688+
authors,
658689
lines_of_code: Some(42),
659690
crate_size: 256256,
660691
releases: 5,
661692
}
662693
}
663694

664-
fn create_overflow_test_data() -> OgImageData<'static> {
665-
static AUTHORS: &[OgImageAuthorData<'_>] = &[
666-
author_with_avatar("alice-wonderland"),
695+
fn create_overflow_authors(server_url: &str) -> Vec<OgImageAuthorData<'_>> {
696+
vec![
697+
author_with_avatar("alice-wonderland", format!("{server_url}/test-avatar.png")),
667698
author("bob-the-builder"),
668-
author_with_avatar("charlie-brown"),
699+
author_with_avatar("charlie-brown", format!("{server_url}/test-avatar.jpg")),
669700
author("diana-prince"),
670-
author_with_avatar("edward-scissorhands"),
701+
author_with_avatar(
702+
"edward-scissorhands",
703+
format!("{server_url}/test-avatar.png"),
704+
),
671705
author("fiona-apple"),
672-
author("george-washington"),
673-
author_with_avatar("helen-keller"),
706+
author_with_avatar(
707+
"george-washington",
708+
format!("{server_url}/test-avatar.webp"),
709+
),
710+
author_with_avatar("helen-keller", format!("{server_url}/test-avatar.jpg")),
674711
author("isaac-newton"),
675712
author("jane-doe"),
676-
];
713+
]
714+
}
677715

716+
fn create_overflow_test_data<'a>(authors: &'a [OgImageAuthorData<'a>]) -> OgImageData<'a> {
678717
OgImageData {
679718
name: "super-long-crate-name-for-testing-overflow-behavior",
680719
version: "2.1.0-beta.1+build.12345",
@@ -689,7 +728,7 @@ mod tests {
689728
"serialization",
690729
"networking",
691730
],
692-
authors: AUTHORS,
731+
authors,
693732
lines_of_code: Some(147000),
694733
crate_size: 2847123,
695734
releases: 1432,
@@ -757,7 +796,12 @@ mod tests {
757796
#[tokio::test]
758797
async fn test_generate_og_image_overflow_snapshot() {
759798
let _guard = init_tracing();
760-
let data = create_overflow_test_data();
799+
800+
let server = create_mock_avatar_server().await;
801+
let server_url = server.url();
802+
803+
let authors = create_overflow_authors(&server_url);
804+
let data = create_overflow_test_data(&authors);
761805

762806
if let Some(image_data) = generate_image(data).await {
763807
insta::assert_binary_snapshot!("generated_og_image_overflow.png", image_data);
@@ -777,10 +821,44 @@ mod tests {
777821
#[tokio::test]
778822
async fn test_generate_og_image_escaping_snapshot() {
779823
let _guard = init_tracing();
780-
let data = create_escaping_test_data();
824+
825+
let server = create_mock_avatar_server().await;
826+
let server_url = server.url();
827+
828+
let authors = create_escaping_authors(&server_url);
829+
let data = create_escaping_test_data(&authors);
781830

782831
if let Some(image_data) = generate_image(data).await {
783832
insta::assert_binary_snapshot!("generated_og_image_escaping.png", image_data);
784833
}
785834
}
835+
836+
#[tokio::test]
837+
async fn test_generate_og_image_with_404_avatar() {
838+
let _guard = init_tracing();
839+
840+
let server = create_mock_avatar_server().await;
841+
let server_url = server.url();
842+
843+
// Create test data with a 404 avatar URL - should skip the avatar gracefully
844+
let authors = vec![author_with_avatar(
845+
"test-user",
846+
format!("{server_url}/missing-avatar.png"),
847+
)];
848+
let data = OgImageData {
849+
name: "test-crate-404",
850+
version: "1.0.0",
851+
description: Some("A test crate with 404 avatar"),
852+
license: Some("MIT"),
853+
tags: &["testing"],
854+
authors: &authors,
855+
lines_of_code: Some(1000),
856+
crate_size: 42012,
857+
releases: 1,
858+
};
859+
860+
if let Some(image_data) = generate_image(data).await {
861+
insta::assert_binary_snapshot!("404-avatar.png", image_data);
862+
}
863+
}
786864
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/crates_io_og_image/src/lib.rs
3+
expression: image_data
4+
extension: png
5+
snapshot_kind: binary
6+
---
Loading
Loading
Binary file not shown.

0 commit comments

Comments
 (0)