use super::*;
fn version_with_files(files: Vec<VersionFile>) -> VersionInfo {
VersionInfo {
id: "version-id".to_owned(),
project_id: "project-id".to_owned(),
name: "Version 1".to_owned(),
version_number: "1.0.0".to_owned(),
game_versions: vec!["1.21.1".to_owned()],
loaders: vec!["fabric".to_owned()],
version_type: VersionType::Release,
dependencies: Vec::new(),
date_published: String::new(),
files,
}
}
#[test]
fn version_dependencies_and_release_type_are_deserialized() {
let version: VersionInfo = serde_json::from_str(
r#"{
"id": "version",
"project_id": "project",
"name": "Beta",
"version_number": "2.0.0",
"game_versions": ["1.21.1"],
"loaders": ["fabric"],
"version_type": "beta",
"dependencies": [{
"project_id": "fabric-api",
"dependency_type": "required"
}],
"files": []
}"#,
)
.unwrap();
assert_eq!(version.version_type, VersionType::Beta);
assert_eq!(version.dependencies.len(), 1);
assert_eq!(
version.dependencies[0].project_id.as_deref(),
Some("fabric-api")
);
assert_eq!(
version.dependencies[0].dependency_type,
DependencyType::Required
);
}
#[test]
fn only_exclusively_library_categorized_projects_are_cleanup_eligible() {
let project = |categories: &[&str]| ProjectInfo {
id: "project".to_owned(),
slug: "project".to_owned(),
title: "Project".to_owned(),
description: String::new(),
body: String::new(),
icon_url: None,
categories: categories
.iter()
.map(|category| (*category).to_owned())
.collect(),
additional_categories: Vec::new(),
project_type: "mod".to_owned(),
loaders: Vec::new(),
};
assert!(project(&["library"]).is_library_only());
assert!(project(&["api-and-library"]).is_library_only());
assert!(!project(&[]).is_library_only());
assert!(!project(&["library", "utility"]).is_library_only());
}
#[test]
fn discovery_mod_facets_include_instance_compatibility() {
let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric);
assert_eq!(
serde_json::from_str::<Vec<Vec<String>>>(&facets).unwrap(),
vec![
vec!["project_type:mod"],
vec!["versions:1.21.1"],
vec!["categories:fabric"],
]
);
}
#[test]
fn discovery_resource_pack_facets_do_not_require_loader() {
let facets = discovery_facets(ContentKind::ResourcePack, "1.20.1", ModLoader::Forge);
assert_eq!(
serde_json::from_str::<Vec<Vec<String>>>(&facets).unwrap(),
vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]]
);
}
#[test]
fn discovery_datapack_facets_use_the_datapack_project_type() {
let facets = discovery_facets(ContentKind::DataPack, "1.21.1", ModLoader::Fabric);
assert_eq!(
serde_json::from_str::<Vec<Vec<String>>>(&facets).unwrap(),
vec![vec!["all_project_types:datapack"], vec!["versions:1.21.1"]]
);
}
#[test]
fn compatible_mod_versions_filter_by_game_and_loader() {
let url = content_versions_url(
"https://example.test/v2",
"fabric-api",
ContentKind::Mod,
"1.21.1",
ModLoader::Fabric,
);
assert_eq!(
url,
"https://example.test/v2/project/fabric-api/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22fabric%22%5D"
);
}
#[test]
fn compatible_resource_pack_versions_do_not_filter_by_loader() {
let url = content_versions_url(
"https://example.test/v2",
"stay-true",
ContentKind::ResourcePack,
"1.21.1",
ModLoader::Fabric,
);
assert_eq!(
url,
"https://example.test/v2/project/stay-true/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D"
);
}
#[test]
fn compatible_datapack_versions_filter_by_datapack_loader() {
let url = content_versions_url(
"https://example.test/v2",
"terralith",
ContentKind::DataPack,
"1.21.1",
ModLoader::Fabric,
);
assert_eq!(
url,
"https://example.test/v2/project/terralith/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22datapack%22%5D"
);
}
#[test]
fn primary_file_selection_falls_back_to_first_file() {
let version = version_with_files(vec![
VersionFile {
url: "https://example.test/first.jar".to_owned(),
filename: "first.jar".to_owned(),
size: 1,
primary: false,
hashes: HashMap::new(),
},
VersionFile {
url: "https://example.test/primary.jar".to_owned(),
filename: "primary.jar".to_owned(),
size: 1,
primary: true,
hashes: HashMap::new(),
},
]);
assert_eq!(
select_primary_file(&version).unwrap().filename,
"primary.jar"
);
let fallback = version_with_files(vec![VersionFile {
url: "https://example.test/first.jar".to_owned(),
filename: "first.jar".to_owned(),
size: 1,
primary: false,
hashes: HashMap::new(),
}]);
assert_eq!(
select_primary_file(&fallback).unwrap().filename,
"first.jar"
);
}
#[test]
fn modpack_versions_are_not_limited_to_hardcoded_loaders() {
assert_eq!(
versions_url("https://example.test/v2", "vanilla pack"),
"https://example.test/v2/project/vanilla%20pack/version"
);
}
#[tokio::test]
async fn content_download_skips_an_existing_filename() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("example.jar");
std::fs::write(&path, b"existing").unwrap();
let version = version_with_files(vec![VersionFile {
url: "https://example.test/example.jar".to_owned(),
filename: "example.jar".to_owned(),
size: b"existing".len() as u64,
primary: true,
hashes: HashMap::new(),
}]);
let outcome = download_version_file(&crate::net::HttpClient::new(), &version, directory.path())
.await
.unwrap();
assert_eq!(outcome, DownloadOutcome::SkippedExisting(path));
assert_eq!(
std::fs::read(directory.path().join("example.jar")).unwrap(),
b"existing"
);
}
#[tokio::test]
async fn content_download_rejects_provider_path_components() {
let directory = tempfile::tempdir().unwrap();
let version = version_with_files(vec![VersionFile {
url: "https://example.test/escape.jar".to_owned(),
filename: "../escape.jar".to_owned(),
size: 1,
primary: true,
hashes: HashMap::new(),
}]);
let error = download_version_file(&crate::net::HttpClient::new(), &version, directory.path())
.await
.unwrap_err();
assert!(error.to_string().contains("Invalid provider filename"));
assert!(
!directory
.path()
.parent()
.unwrap()
.join("escape.jar")
.exists()
);
}
#[tokio::test]
async fn staged_update_replaces_the_old_file_and_cleans_its_backup() {
let directory = tempfile::tempdir().unwrap();
let installed = directory.path().join("example.jar");
let temporary = directory.path().join(".example.jar.rmcl-download");
let backup = directory.path().join(".example.jar.rmcl-backup");
std::fs::write(&installed, b"old version").unwrap();
std::fs::write(&temporary, b"new version").unwrap();
replace_installed_file(&temporary, &installed, &installed, &backup)
.await
.unwrap();
assert_eq!(std::fs::read(installed).unwrap(), b"new version");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
#[test]
fn discovery_search_ignores_new_modrinth_enum_values() {
let response: DiscoverySearchResponse = serde_json::from_str(
r#"{
"hits": [{
"project_id": "project-id",
"slug": "example",
"title": "Example",
"description": "Example project",
"downloads": 42,
"icon_url": null,
"client_side": "unknown",
"server_side": "unknown"
}],
"total_hits": 1
}"#,
)
.unwrap();
assert_eq!(response.hits.len(), 1);
assert_eq!(response.hits[0].project_id, "project-id");
assert_eq!(response.total_hits, 1);
}
#[test]
fn discovery_search_treats_blank_icon_urls_as_missing() {
let hit: DiscoverySearchHit = serde_json::from_str(
r#"{
"project_id": "project-id",
"slug": "example",
"title": "Example",
"description": "Example project",
"icon_url": " "
}"#,
)
.unwrap();
assert!(DiscoveryProject::from(hit).icon_url.is_none());
}
#[rstest::rstest]
#[case::ascii_unreserved("abcXYZ0-9_.~", "abcXYZ0-9_.~")]
#[case::space("hello world", "hello%20world")]
#[case::reserved("/?&=#", "%2F%3F%26%3D%23")]
#[case::utf8_emoji("\u{2603}", "%E2%98%83")]
#[case::empty("", "")]
fn url_encode_handles(#[case] input: &str, #[case] expected: &str) {
assert_eq!(url_encode(input), expected);
}