#[cfg(test)] mod tests { use std::collections::HashMap; use async_trait::async_trait; use chrono::{DateTime, Utc}; use modrinth_content_management::{ ContentMetadataProvider, ContentType, Dependency, DependencyType, Error, ResolutionPreferences, ResolveContentRequest, SkippedReason, Version, resolve_content, }; const QUILT_FABRIC_API_EXCEPTION_PROJECT_ID: &str = "P7dR8mSH"; #[derive(Default)] struct MemoryProvider { versions: HashMap, project_versions: HashMap>, } #[async_trait] impl ContentMetadataProvider for MemoryProvider { async fn get_version( &mut self, version_id: &str, ) -> Result, Error> { Ok(self.versions.get(version_id).cloned()) } async fn get_project_versions( &mut self, project_id: &str, ) -> Result, Error> { Ok(self .project_versions .get(project_id) .into_iter() .flatten() .filter_map(|id| self.versions.get(id)) .cloned() .collect()) } } impl MemoryProvider { fn with_versions(mut self, versions: Vec) -> Self { for version in versions { self.project_versions .entry(version.project_id.clone()) .or_default() .push(version.id.clone()); self.versions.insert(version.id.clone(), version); } self } fn with_project_versions( mut self, project_id: &str, version_ids: &[&str], ) -> Self { self.project_versions.insert( project_id.to_string(), version_ids.iter().map(|id| id.to_string()).collect(), ); self } } fn version( id: &str, project_id: &str, date: &str, game_versions: &[&str], loaders: &[&str], dependencies: Vec, ) -> Version { version_with_channel( id, project_id, date, "release", game_versions, loaders, dependencies, ) } fn version_with_channel( id: &str, project_id: &str, date: &str, version_type: &str, game_versions: &[&str], loaders: &[&str], dependencies: Vec, ) -> Version { Version { id: id.to_string(), project_id: project_id.to_string(), date_published: DateTime::parse_from_rfc3339(date) .unwrap() .with_timezone(&Utc), version_type: version_type.to_string(), dependencies, game_versions: game_versions .iter() .map(|v| v.to_string()) .collect(), loaders: loaders.iter().map(|v| v.to_string()).collect(), } } fn dependency( project_id: Option<&str>, version_id: Option<&str>, dependency_type: DependencyType, ) -> Dependency { Dependency { version_id: version_id.map(str::to_string), project_id: project_id.map(str::to_string), file_name: None, dependency_type, } } fn required_project_dependency(project_id: &str) -> Dependency { dependency(Some(project_id), None, DependencyType::Required) } fn required_version_dependency(version_id: &str) -> Dependency { dependency(None, Some(version_id), DependencyType::Required) } fn request(project_id: &str) -> ResolveContentRequest { ResolveContentRequest { project_id: project_id.to_string(), version_id: None, content_type: ContentType::Mod, selected: ResolutionPreferences::default(), target: ResolutionPreferences { game_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], }, existing_project_ids: Vec::new(), excluded_project_ids: Vec::new(), force_project_ids: Vec::new(), } } #[tokio::test] async fn explicit_primary_version_is_used() { let provider = MemoryProvider::default().with_versions(vec![version( "v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], )]); let mut request = request("p1"); request.version_id = Some("v1".to_string()); let plan = resolve_content(provider, request).await.unwrap(); assert_eq!(plan.primary.version_id, "v1"); } #[tokio::test] async fn explicit_primary_version_bypasses_target_filters() { let provider = MemoryProvider::default().with_versions(vec![version( "v1", "p1", "2024-01-01T00:00:00Z", &["1.21.1"], &["neoforge"], vec![], )]); let mut request = request("p1"); request.version_id = Some("v1".to_string()); let plan = resolve_content(provider, request).await.unwrap(); assert_eq!(plan.primary.version_id, "v1"); } #[tokio::test] async fn newest_matching_primary_version_is_selected() { let provider = MemoryProvider::default().with_versions(vec![ version( "old", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), version( "new", "p1", "2024-02-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), ]); let plan = resolve_content(provider, request("p1")).await.unwrap(); assert_eq!(plan.primary.version_id, "new"); } #[tokio::test] async fn automatic_primary_prefers_a_release_over_a_newer_beta() { let provider = MemoryProvider::default().with_versions(vec![ version_with_channel( "iris-release", "iris", "2026-05-01T00:00:00Z", "release", &["1.21.1"], &["neoforge"], vec![dependency( Some("sodium"), Some("sodium-release"), DependencyType::Required, )], ), version_with_channel( "iris-newer-beta", "iris", "2026-06-01T00:00:00Z", "beta", &["1.21.1"], &["neoforge"], vec![], ), version_with_channel( "sodium-release", "sodium", "2026-04-01T00:00:00Z", "release", &["1.21.1"], &["neoforge"], vec![], ), ]); let mut install_request = request("iris"); install_request.target.game_versions = vec!["1.21.1".to_string()]; install_request.target.loaders = vec!["neoforge".to_string()]; let plan = resolve_content(provider, install_request).await.unwrap(); assert_eq!(plan.primary.version_id, "iris-release"); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].version_id, "sodium-release"); } #[tokio::test] async fn project_only_dependency_uses_the_target_runtime() { let provider = MemoryProvider::default().with_versions(vec![ version_with_channel( "primary", "primary", "2026-06-01T00:00:00Z", "release", &["1.21.1"], &["neoforge"], vec![required_project_dependency("dependency")], ), version_with_channel( "wrong-game-version", "dependency", "2026-07-01T00:00:00Z", "beta", &["1.21.2"], &["neoforge"], vec![], ), version_with_channel( "wrong-loader", "dependency", "2026-06-15T00:00:00Z", "beta", &["1.21.1"], &["fabric"], vec![], ), version_with_channel( "matching-runtime", "dependency", "2026-05-01T00:00:00Z", "release", &["1.21.1"], &["neoforge"], vec![], ), ]); let mut install_request = request("primary"); install_request.version_id = Some("primary".to_string()); install_request.target.game_versions = vec!["1.21.1".to_string()]; install_request.target.loaders = vec!["neoforge".to_string()]; let plan = resolve_content(provider, install_request).await.unwrap(); assert_eq!(plan.primary.version_id, "primary"); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].version_id, "matching-runtime"); } #[tokio::test] async fn project_only_dependency_selects_matching_version() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![required_project_dependency("dep")], ), version( "depv1", "dep", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), ]); let plan = resolve_content(provider, request("p1")).await.unwrap(); assert_eq!(plan.dependencies[0].project_id, "dep"); assert_eq!( plan.dependencies[0].dependent_on_version_id.as_deref(), Some("p1v1") ); } #[tokio::test] async fn exact_version_dependency_bypasses_target_filters() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![required_version_dependency("depv1")], ), version( "depv1", "dep", "2024-01-01T00:00:00Z", &["1.19.4"], &["forge"], vec![], ), ]); let plan = resolve_content(provider, request("p1")).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].project_id, "dep"); assert_eq!(plan.dependencies[0].version_id, "depv1"); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn already_installed_dependencies_are_skipped() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![required_project_dependency("dep")], ), version( "depv1", "dep", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), ]); let mut request = request("p1"); request.existing_project_ids = vec!["dep".to_string()]; let plan = resolve_content(provider, request).await.unwrap(); assert!(plan.dependencies.is_empty()); assert_eq!(plan.skipped[0].reason, SkippedReason::AlreadyInstalled); } #[tokio::test] async fn duplicate_dependency_projects_are_skipped() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![ required_project_dependency("dep"), required_project_dependency("dep"), ], ), version( "depv1", "dep", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), ]); let plan = resolve_content(provider, request("p1")).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.skipped[0].reason, SkippedReason::DuplicateProject); } #[tokio::test] async fn conflicting_dependency_versions_are_skipped() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![ dependency( Some("dep"), Some("depv1"), DependencyType::Required, ), dependency( Some("dep"), Some("depv2"), DependencyType::Required, ), ], ), version( "depv1", "dep", "2024-01-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), version( "depv2", "dep", "2024-02-01T00:00:00Z", &["1.20.1"], &["fabric"], vec![], ), ]); let plan = resolve_content(provider, request("p1")).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!( plan.skipped[0].reason, SkippedReason::ConflictingDependency ); } #[tokio::test] async fn quilt_instances_replace_fabric_api_with_quilted_fabric_api() { let provider = MemoryProvider::default().with_versions(vec![ version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["quilt"], vec![required_project_dependency( QUILT_FABRIC_API_EXCEPTION_PROJECT_ID, )], ), version( "qfabv1", "qvIfYCYJ", "2024-01-01T00:00:00Z", &["1.20.1"], &["quilt"], vec![], ), ]); let mut request = request("p1"); request.target.loaders = vec!["quilt".to_string()]; let plan = resolve_content(provider, request).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].project_id, "qvIfYCYJ"); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn iris_metadata_gap_injects_missing_sodium_dependency() { let provider = MemoryProvider::default().with_versions(vec![ version( "Cjwm9s3i", "YL57xq9U", "2023-12-13T02:48:08Z", &["1.20.2"], &["fabric"], vec![], ), version( "pmgeU5yX", "AANobbMI", "2023-12-09T04:07:46Z", &["1.20.2"], &["fabric"], vec![], ), ]); let mut request = request("YL57xq9U"); request.target.game_versions = vec!["1.20.2".to_string()]; let plan = resolve_content(provider, request).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].project_id, "AANobbMI"); assert_eq!(plan.dependencies[0].version_id, "pmgeU5yX"); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn iris_metadata_gap_is_not_duplicated_once_upstream_is_fixed() { let provider = MemoryProvider::default().with_versions(vec![ version( "Cjwm9s3i", "YL57xq9U", "2023-12-13T02:48:08Z", &["1.20.2"], &["fabric"], vec![required_project_dependency("AANobbMI")], ), version( "pmgeU5yX", "AANobbMI", "2023-12-09T04:07:46Z", &["1.20.2"], &["fabric"], vec![], ), ]); let mut request = request("YL57xq9U"); request.target.game_versions = vec!["1.20.2".to_string()]; let plan = resolve_content(provider, request).await.unwrap(); assert_eq!(plan.dependencies.len(), 1); assert_eq!(plan.dependencies[0].version_id, "pmgeU5yX"); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn iris_metadata_gap_does_not_apply_to_other_versions() { let provider = MemoryProvider::default().with_versions(vec![version( "other", "YL57xq9U", "2024-01-01T00:00:00Z", &["1.20.2"], &["fabric"], vec![], )]); let mut request = request("YL57xq9U"); request.target.game_versions = vec!["1.20.2".to_string()]; let plan = resolve_content(provider, request).await.unwrap(); assert!(plan.dependencies.is_empty()); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn mods_reject_datapack_versions_when_a_loader_is_requested() { let provider = MemoryProvider::default().with_versions(vec![version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["datapack"], vec![], )]); assert!(matches!( resolve_content(provider, request("p1")).await, Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1" )); } #[tokio::test] async fn neoforge_rejects_neo_loader_alias() { let provider = MemoryProvider::default().with_versions(vec![version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["neo"], vec![], )]); let mut request = request("p1"); request.target.loaders = vec!["neoforge".to_string()]; assert!(matches!( resolve_content(provider, request).await, Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1" )); } #[tokio::test] async fn paper_rejects_bukkit_loader_alias() { let provider = MemoryProvider::default().with_versions(vec![version( "p1v1", "p1", "2024-01-01T00:00:00Z", &["1.20.1"], &["bukkit"], vec![], )]); let mut request = request("p1"); request.content_type = ContentType::Plugin; request.target.loaders = vec!["paper".to_string()]; assert!(matches!( resolve_content(provider, request).await, Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1" )); } #[tokio::test] async fn modpack_embedded_dependencies_are_not_installed() { let provider = MemoryProvider::default() .with_versions(vec![version( "Ur9uoHH5", "shFhR8Vx", "2025-01-19T06:39:41.349487Z", &["1.20.1"], &["fabric"], vec![ dependency( Some("embedded-a"), None, DependencyType::Embedded, ), dependency( Some("embedded-b"), None, DependencyType::Embedded, ), dependency( Some("embedded-c"), None, DependencyType::Embedded, ), ], )]) .with_project_versions("better-mc-fabric-bmc2", &["Ur9uoHH5"]); let plan = resolve_content( provider, ResolveContentRequest { project_id: "better-mc-fabric-bmc2".to_string(), version_id: None, content_type: ContentType::ModPack, selected: ResolutionPreferences::default(), target: ResolutionPreferences::default(), existing_project_ids: Vec::new(), excluded_project_ids: Vec::new(), force_project_ids: Vec::new(), }, ) .await .unwrap(); assert_eq!(plan.primary.project_id, "shFhR8Vx"); assert_eq!(plan.primary.version_id, "Ur9uoHH5"); assert!(plan.dependencies.is_empty()); assert!(plan.skipped.is_empty()); } #[tokio::test] async fn required_dependencies_resolve_transitively() { let provider = MemoryProvider::default().with_versions(vec![ version( "pKFEfjEB", "DdAlVT8M", "2026-06-07T21:21:24.772638Z", &["1.21.1", "1.21.2"], &["neoforge"], vec![ dependency( Some("LNytGWDc"), Some("UjX6dr61"), DependencyType::Required, ), dependency( Some("oWaK0Q19"), Some("YhZLrAFC"), DependencyType::Required, ), ], ), version( "UjX6dr61", "LNytGWDc", "2026-04-21T22:20:03.579201Z", &["1.21.1"], &["neoforge"], vec![], ), version( "YhZLrAFC", "oWaK0Q19", "2026-05-21T22:20:03.579201Z", &["1.21.1"], &["neoforge"], vec![ dependency( Some("T9PomCSv"), None, DependencyType::Required, ), dependency( Some("LNytGWDc"), Some("UjX6dr61"), DependencyType::Required, ), ], ), version( "hyQUls27", "T9PomCSv", "2026-06-17T04:32:50.469591Z", &["1.21.1"], &["fabric"], vec![], ), version( "1L6XJqnY", "T9PomCSv", "2026-06-17T04:32:49.279302Z", &["1.21.1"], &["neoforge"], vec![], ), ]); let plan = resolve_content( provider, ResolveContentRequest { project_id: "DdAlVT8M".to_string(), version_id: Some("pKFEfjEB".to_string()), content_type: ContentType::Mod, selected: ResolutionPreferences::default(), target: ResolutionPreferences { game_versions: vec![ "1.21.1".to_string(), "1.21.2".to_string(), ], loaders: vec!["neoforge".to_string()], }, existing_project_ids: Vec::new(), excluded_project_ids: Vec::new(), force_project_ids: Vec::new(), }, ) .await .unwrap(); assert_eq!(plan.primary.project_id, "DdAlVT8M"); assert_eq!(plan.primary.version_id, "pKFEfjEB"); assert_eq!(plan.dependencies.len(), 3); assert!(plan.dependencies.iter().any(|dependency| { dependency.project_id == "LNytGWDc" && dependency.version_id == "UjX6dr61" && dependency.dependent_on_version_id.as_deref() == Some("pKFEfjEB") })); assert!(plan.dependencies.iter().any(|dependency| { dependency.project_id == "oWaK0Q19" && dependency.version_id == "YhZLrAFC" && dependency.dependent_on_version_id.as_deref() == Some("pKFEfjEB") })); assert!(plan.dependencies.iter().any(|dependency| { dependency.project_id == "T9PomCSv" && dependency.version_id == "1L6XJqnY" && dependency.dependent_on_version_id.as_deref() == Some("YhZLrAFC") })); assert_eq!(plan.skipped.len(), 1); assert_eq!( plan.skipped[0].reason, modrinth_content_management::SkippedReason::DuplicateProject ); assert_eq!(plan.skipped[0].project_id, "LNytGWDc"); } }