mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use super::*;
|
|
use crate::{
|
|
constants::AssetExtension,
|
|
server::format_policy::{default_transform_quality, transform_static_quality_default},
|
|
test_fixtures::{JXL_4100_LOSSY_ALPHA, fixture_h264_mp4, fixture_jpeg, synthetic_png},
|
|
};
|
|
use axum::body::to_bytes;
|
|
|
|
const TRANSFORM_WIDTH: u32 = 32;
|
|
|
|
fn test_runtime() -> TransformRuntime {
|
|
let cfg = Config::load_from_iter([(
|
|
"FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(),
|
|
"secret".to_owned(),
|
|
)])
|
|
.expect("config loads");
|
|
TransformRuntime::new(&cfg, &Arc::new(metrics::Metrics::new()))
|
|
.expect("test transform runtime limits are valid")
|
|
}
|
|
|
|
fn attachment_cache_key(
|
|
filename: &str,
|
|
quality: ImageQuality,
|
|
resize_mode: Option<ResizeMode>,
|
|
) -> String {
|
|
transform_cache_key(TransformCacheKeyInput {
|
|
route: TransformRoute::Attachment,
|
|
asset_kind: None,
|
|
cache_identity: filename,
|
|
width: Some(TRANSFORM_WIDTH),
|
|
height: None,
|
|
format: OutputFormat::WebP,
|
|
quality: Some(quality),
|
|
animated: false,
|
|
effort: None,
|
|
resize_mode,
|
|
})
|
|
}
|
|
|
|
async fn transform_qualities_reaching_the_encoder(
|
|
fixture: Vec<u8>,
|
|
content_type: &str,
|
|
filename: &str,
|
|
) -> Vec<ImageQuality> {
|
|
let runtime = test_runtime();
|
|
let params = HashMap::from([
|
|
("format".to_owned(), "webp".to_owned()),
|
|
("width".to_owned(), TRANSFORM_WIDTH.to_string()),
|
|
]);
|
|
let headers = HeaderMap::new();
|
|
let response = serve_bytes_or_transform(
|
|
&runtime,
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: Bytes::from(fixture),
|
|
content_type: content_type.to_owned(),
|
|
cache_identity: filename,
|
|
filename,
|
|
route: TransformRoute::Attachment,
|
|
params: ¶ms,
|
|
headers: &headers,
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
StatusCode::OK,
|
|
response.status(),
|
|
"{filename} did not reach the transform path"
|
|
);
|
|
[
|
|
ImageQuality::Lossless,
|
|
ImageQuality::High,
|
|
ImageQuality::Auto,
|
|
ImageQuality::Low,
|
|
]
|
|
.into_iter()
|
|
.filter(|quality| {
|
|
[None, Some(ResizeMode::Fit)]
|
|
.into_iter()
|
|
.any(|resize_mode| {
|
|
runtime
|
|
.cache()
|
|
.get(&attachment_cache_key(filename, *quality, resize_mode))
|
|
.is_some()
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn lossy_source_transform_defaults_to_lossy_output() {
|
|
for lossy in [
|
|
AssetExtension::Jpeg,
|
|
AssetExtension::Heic,
|
|
AssetExtension::Heif,
|
|
] {
|
|
assert_eq!(
|
|
ImageQuality::High,
|
|
default_transform_quality(
|
|
OutputFormat::WebP,
|
|
false,
|
|
transform_static_quality_default(Some(lossy))
|
|
),
|
|
"{} source must not be re-encoded losslessly",
|
|
lossy.name()
|
|
);
|
|
}
|
|
for lossless in [
|
|
AssetExtension::Png,
|
|
AssetExtension::Apng,
|
|
AssetExtension::Gif,
|
|
AssetExtension::Avif,
|
|
AssetExtension::Webp,
|
|
] {
|
|
assert_eq!(
|
|
ImageQuality::Lossless,
|
|
default_transform_quality(
|
|
OutputFormat::WebP,
|
|
false,
|
|
transform_static_quality_default(Some(lossless))
|
|
),
|
|
"{} source must keep the lossless default",
|
|
lossless.name()
|
|
);
|
|
}
|
|
assert_eq!(
|
|
ImageQuality::Lossless,
|
|
default_transform_quality(
|
|
OutputFormat::WebP,
|
|
false,
|
|
transform_static_quality_default(None)
|
|
)
|
|
);
|
|
assert_eq!(
|
|
ImageQuality::Auto,
|
|
default_transform_quality(
|
|
OutputFormat::WebP,
|
|
true,
|
|
transform_static_quality_default(Some(AssetExtension::Jpeg))
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_transform_route_sends_lossy_sources_to_a_lossy_encode() {
|
|
assert_eq!(
|
|
vec![ImageQuality::High],
|
|
transform_qualities_reaching_the_encoder(fixture_jpeg(), "image/jpeg", "photo.jpg").await,
|
|
"a jpeg attachment must not be re-encoded losslessly"
|
|
);
|
|
assert_eq!(
|
|
vec![ImageQuality::Lossless],
|
|
transform_qualities_reaching_the_encoder(synthetic_png(48, 48), "image/png", "art.png")
|
|
.await,
|
|
"a png attachment must keep the lossless encode"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn video_poster_transform_route_sends_decoded_frames_to_a_lossy_encode() {
|
|
assert_eq!(
|
|
vec![ImageQuality::High],
|
|
transform_qualities_reaching_the_encoder(fixture_h264_mp4(), "video/mp4", "clip.mp4").await,
|
|
"a decoded video frame is already lossy and must not be re-encoded losslessly"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_redundant_format_parameter_shares_the_implicit_transform_cache_entry() {
|
|
const KEY: &str = "attachment:art.png|asset_kind=not-applicable|w=32|h=none|fmt=png|q=lossless|anim=false|effort=none|resize=fit";
|
|
for params in [
|
|
HashMap::from([("width".to_owned(), TRANSFORM_WIDTH.to_string())]),
|
|
HashMap::from([
|
|
("width".to_owned(), TRANSFORM_WIDTH.to_string()),
|
|
("format".to_owned(), "png".to_owned()),
|
|
]),
|
|
] {
|
|
let runtime = test_runtime();
|
|
let headers = HeaderMap::new();
|
|
let response = serve_bytes_or_transform(
|
|
&runtime,
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: Bytes::from(synthetic_png(48, 48)),
|
|
content_type: "image/png".to_owned(),
|
|
cache_identity: "art.png",
|
|
filename: "art.png",
|
|
route: TransformRoute::Attachment,
|
|
params: ¶ms,
|
|
headers: &headers,
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert!(
|
|
runtime.cache().get(KEY).is_some(),
|
|
"naming the format the url already implies must not mint a second cache key"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn attachment_transform(data: &[u8], filename: &str, params: &[(&str, &str)]) -> Response {
|
|
let params = params
|
|
.iter()
|
|
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
|
|
.collect::<HashMap<_, _>>();
|
|
serve_bytes_or_transform(
|
|
&test_runtime(),
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: Bytes::copy_from_slice(data),
|
|
content_type: "application/octet-stream".to_owned(),
|
|
cache_identity: filename,
|
|
filename,
|
|
route: TransformRoute::Attachment,
|
|
params: ¶ms,
|
|
headers: &HeaderMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_full_size_transform_too_large_to_process_serves_the_original() {
|
|
let png = synthetic_png(6145, 4097);
|
|
let response = attachment_transform(&png, "big.png", &[("format", "webp")]).await;
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert_eq!(
|
|
Some("image/png"),
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
assert_eq!(png, body);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_waiter_releases_its_source_while_the_leader_runs() {
|
|
const KEY: &str = "attachment:art.png|asset_kind=not-applicable|w=none|h=none|fmt=webp|q=lossless|anim=false|effort=none|resize=fit";
|
|
let runtime = test_runtime();
|
|
let params = HashMap::from([("format".to_owned(), "webp".to_owned())]);
|
|
let headers = HeaderMap::new();
|
|
let source = Bytes::from(synthetic_png(48, 48));
|
|
let probe = source.clone();
|
|
let (release, released) = tokio::sync::oneshot::channel::<()>();
|
|
let leader =
|
|
runtime
|
|
.cache()
|
|
.get_or_run(KEY.to_owned(), OutputFormat::WebP, None, || async move {
|
|
let _ = released.await;
|
|
Ok(MediaBytes::from(vec![0u8; 4]))
|
|
});
|
|
let waiter = async {
|
|
let response = serve_bytes_or_transform(
|
|
&runtime,
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: source,
|
|
content_type: "application/octet-stream".to_owned(),
|
|
cache_identity: "art.png",
|
|
filename: "art.png",
|
|
route: TransformRoute::Attachment,
|
|
params: ¶ms,
|
|
headers: &headers,
|
|
},
|
|
);
|
|
tokio::pin!(response);
|
|
assert!(
|
|
tokio::time::timeout(std::time::Duration::from_millis(100), response.as_mut())
|
|
.await
|
|
.is_err(),
|
|
"the request waits on the running leader"
|
|
);
|
|
assert!(
|
|
probe.is_unique(),
|
|
"a waiter must not pin its source while the leader runs"
|
|
);
|
|
let _ = release.send(());
|
|
response.await
|
|
};
|
|
let (leader, response) = tokio::join!(leader, waiter);
|
|
leader.expect("leader publishes");
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_resized_transform_too_large_to_process_still_fails() {
|
|
let response = attachment_transform(
|
|
&synthetic_png(6145, 4097),
|
|
"big.png",
|
|
&[("format", "webp"), ("width", "6145")],
|
|
)
|
|
.await;
|
|
assert_eq!(StatusCode::BAD_REQUEST, response.status());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_full_size_transform_too_large_to_process_never_serves_an_undisplayable_original() {
|
|
let response =
|
|
attachment_transform(JXL_4100_LOSSY_ALPHA, "big.jxl", &[("format", "webp")]).await;
|
|
assert_eq!(StatusCode::BAD_REQUEST, response.status());
|
|
}
|
|
|
|
#[test]
|
|
fn the_budget_leaves_room_for_the_cache_and_headroom() {
|
|
assert_eq!(
|
|
2560 << 20,
|
|
native_memory_budget_bytes(4096 << 20, 1024 << 20)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cache_larger_than_the_pod_takes_at_most_half_of_it() {
|
|
assert_eq!(192 << 20, native_memory_budget_bytes(512 << 20, 1 << 30));
|
|
assert_eq!(384 << 20, native_memory_budget_bytes(1 << 30, 1 << 30));
|
|
}
|
|
|
|
#[test]
|
|
fn the_budget_never_drops_below_its_floor() {
|
|
assert_eq!(128 << 20, native_memory_budget_bytes(64 << 20, 0));
|
|
}
|