From d3f998b243a05b748dcd0d9b68a7c13c3adeee5b Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:53:46 +0100 Subject: [PATCH 1/3] v2 versions query params to fetch loader data --- apps/labrinth/src/auth/checks.rs | 3 + apps/labrinth/src/routes/v2/versions.rs | 317 ++++++++++--- apps/labrinth/src/routes/v3/versions.rs | 250 +++++----- .../tests/v2/bulk_project_versions.rs | 449 ++++++++++++++++++ apps/labrinth/tests/v2_tests.rs | 1 + 5 files changed, 851 insertions(+), 169 deletions(-) create mode 100644 apps/labrinth/tests/v2/bulk_project_versions.rs diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index bf954e7fbd..f83cb1d9ad 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -224,6 +224,9 @@ pub async fn filter_visible_versions( ) .await .wrap_api_err("filtering visible version ids")?; + let filtered_version_ids = filtered_version_ids + .into_iter() + .collect::>(); versions.retain(|x| filtered_version_ids.contains(&x.inner.id)); let missing = get_files_missing_attribution( diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 08d3852eb7..dba18da644 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -43,6 +43,63 @@ pub struct VersionListFilters { pub include_changelog: bool, } +impl VersionListFilters { + fn into_v3(self) -> v3::versions::VersionListFilters { + let filters = self; + let loaders = if let Some(loaders) = filters.loaders { + if let Ok(mut loaders) = + serde_json::from_str::>(&loaders) + { + loaders.push("mrpack".to_string()); + Some(loaders) + } else { + None + } + } else { + None + }; + + let loader_fields = if let Some(game_versions) = filters.game_versions { + // TODO: extract this logic which is similar to the other v2->v3 version_file functions + let mut loader_fields = HashMap::new(); + serde_json::from_str::>(&game_versions) + .ok() + .and_then(|versions| { + let mut game_versions: Vec = vec![]; + for gv in versions { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields + .insert("game_versions".to_string(), game_versions); + + if let Some(ref loaders) = loaders { + loader_fields.insert( + "loaders".to_string(), + loaders + .iter() + .map(|x| serde_json::json!(x.clone())) + .collect(), + ); + } + + serde_json::to_string(&loader_fields).ok() + }) + } else { + None + }; + + v3::versions::VersionListFilters { + loader_fields, + loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()), + featured: filters.featured, + version_type: filters.version_type, + limit: filters.limit, + offset: filters.offset, + include_changelog: filters.include_changelog, + } + } +} + fn default_true() -> bool { true } @@ -94,55 +151,7 @@ pub async fn version_list( return Ok(response); } - let loaders = if let Some(loaders) = filters.loaders { - if let Ok(mut loaders) = serde_json::from_str::>(&loaders) { - loaders.push("mrpack".to_string()); - Some(loaders) - } else { - None - } - } else { - None - }; - - let loader_fields = if let Some(game_versions) = filters.game_versions { - // TODO: extract this logic which is similar to the other v2->v3 version_file functions - let mut loader_fields = HashMap::new(); - serde_json::from_str::>(&game_versions) - .ok() - .and_then(|versions| { - let mut game_versions: Vec = vec![]; - for gv in versions { - game_versions.push(serde_json::json!(gv.clone())); - } - loader_fields - .insert("game_versions".to_string(), game_versions); - - if let Some(ref loaders) = loaders { - loader_fields.insert( - "loaders".to_string(), - loaders - .iter() - .map(|x| serde_json::json!(x.clone())) - .collect(), - ); - } - - serde_json::to_string(&loader_fields).ok() - }) - } else { - None - }; - - let filters = v3::versions::VersionListFilters { - loader_fields, - loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()), - featured: filters.featured, - version_type: filters.version_type, - limit: filters.limit, - offset: filters.offset, - include_changelog: filters.include_changelog, - }; + let filters = filters.into_v3(); let response = v3::versions::version_list_internal( req, @@ -234,35 +243,227 @@ pub async fn version_project_get( } #[derive(Serialize, Deserialize, utoipa::ToSchema)] -pub struct VersionIds { - pub ids: String, +pub struct VersionsFilters { + pub ids: Option, + pub project_ids: Option, + pub game_versions: Option, + pub loaders: Option, + pub featured: Option, + pub version_type: Option, + pub limit: Option, + pub offset: Option, #[serde(default = "default_true")] pub include_changelog: bool, } -/// Get multiple versions by ID. +/// Get multiple versions by ID, or list versions across projects. #[utoipa::path( tag = "versions", get, operation_id = "getVersions", params( - ("ids" = String, Query, description = "The JSON array of version IDs"), + ("ids" = Option, Query, description = "JSON array of version IDs; exactly one of ids and project_ids is required. Listing filters are ignored for ids"), + ("project_ids" = Option, Query, description = "JSON array of at most 100 project IDs or slugs; missing or inaccessible projects are omitted"), + ("game_versions" = Option, Query, description = "JSON array of game versions to filter project listings by"), + ("loaders" = Option, Query, description = "JSON array of loaders to filter project listings by"), + ("featured" = Option, Query, description = "Featured filter with per-project automatic featured fallback"), + ("version_type" = Option, Query, description = "Filter project listings by version type"), + ("limit" = Option, Query, description = "Global maximum after filtering and visibility; omitted means no limit. Only for project_ids"), + ("offset" = Option, Query, description = "Global offset in newest-first order after filtering and visibility. Only for project_ids"), ("include_changelog" = Option, Query, description = "Whether to include changelog fields") ), - responses((status = 200, description = "Expected response to a valid request", body = Vec)) + responses((status = 200, description = "Expected response to a valid request", body = Vec), (status = 400, description = "Missing or mutually exclusive selectors, more than 100 projects, or invalid parameters")) )] #[get("/versions")] pub async fn versions_get( req: HttpRequest, - web::Query(ids): web::Query, + web::Query(query): web::Query, pool: web::Data, ro_pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = v3::versions::VersionIds { - ids: ids.ids, - include_changelog: ids.include_changelog, + let ids = match (query.ids, query.project_ids) { + (Some(ids), None) => v3::versions::VersionIds { + ids, + include_changelog: query.include_changelog, + }, + (None, Some(project_ids)) => { + let filters = VersionListFilters { + game_versions: query.game_versions, + loaders: query.loaders, + featured: query + .featured + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `featured`")?, + version_type: query + .version_type + .map(|value| { + serde_json::from_value(serde_json::Value::String(value)) + }) + .transpose() + .wrap_request_err("deserializing `version_type`")?, + limit: query + .limit + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `limit`")?, + offset: query + .offset + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `offset`")?, + include_changelog: query.include_changelog, + }; + let project_ids = serde_json::from_str::>(&project_ids) + .wrap_request_err("deserializing `project_ids`")?; + if project_ids.len() > 100 { + return Err(ApiError::Request(eyre::eyre!( + "`project_ids` must contain at most 100 projects" + ))); + } + for (name, value) in [ + ("game_versions", &filters.game_versions), + ("loaders", &filters.loaders), + ] { + if let Some(value) = value { + serde_json::from_str::>(value) + .wrap_request_err_with(|| { + format!("deserializing `{name}`") + })?; + } + } + let requested_loaders = filters + .loaders + .as_deref() + .map(serde_json::from_str::>) + .transpose() + .wrap_request_err("deserializing `loaders`")?; + let user_option = crate::auth::get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ | Scopes::VERSION_READ, + ) + .await + .map(|x| x.1) + .ok(); + let mut projects = crate::database::models::DBProject::get_many( + &project_ids, + &***ro_pool, + &redis, + ) + .await + .wrap_internal_err("fetching projects from database")?; + let visible = crate::auth::checks::filter_visible_project_ids( + projects.iter().map(|p| &p.inner).collect(), + &user_option, + &pool, + false, + ) + .await + .wrap_api_err("filtering visible projects")?; + projects.retain(|p| visible.contains(&p.inner.id)); + let version_ids = projects + .iter() + .flat_map(|p| p.versions.iter().copied()) + .collect::>() + .into_iter() + .collect::>(); + let versions = crate::database::models::DBVersion::get_many( + &version_ids, + &***ro_pool, + &redis, + ) + .await + .wrap_internal_err("fetching versions from database")?; + let mut grouped = HashMap::new(); + for version in versions { + if let Some(loaders) = &requested_loaders { + let legacy_loaders = if version + .loaders + .iter() + .any(|loader| loader == "mrpack") + { + version + .version_fields + .iter() + .find(|field| field.field_name == "mrpack_loaders") + .map(|field| field.value.as_strings()) + .unwrap_or_else(|| version.loaders.clone()) + } else { + version.loaders.clone() + }; + if !legacy_loaders + .iter() + .any(|loader| loaders.contains(loader)) + { + continue; + } + } + grouped + .entry(version.inner.project_id) + .or_insert_with(Vec::new) + .push(version); + } + let mut filters = filters.into_v3(); + let limit = filters.limit.take(); + let offset = filters.offset.take(); + let auto_featured = if filters.featured == Some(true) { + Some( + v3::versions::auto_featured_filters(&ro_pool, &redis) + .await?, + ) + } else { + None + }; + let mut response = Vec::new(); + for mut versions in grouped.into_values() { + versions.sort_by_key(|version| version.inner.id.0); + response.extend( + v3::versions::filter_project_versions( + versions, + &filters, + &ro_pool, + &redis, + auto_featured.as_deref(), + ) + .await?, + ); + } + let mut response = crate::auth::checks::filter_visible_versions( + response, + &user_option, + &ro_pool, + &redis, + ) + .await + .wrap_api_err("filtering visible versions")?; + response.sort_by(|a, b| { + b.date_published + .cmp(&a.date_published) + .then_with(|| a.id.0.cmp(&b.id.0)) + }); + let response = response + .into_iter() + .map(|mut version| { + if !filters.include_changelog { + version.changelog = None; + } + LegacyVersion::from(version) + }) + .skip(offset.unwrap_or(0)) + .take(limit.unwrap_or(usize::MAX)) + .collect::>(); + return Ok(HttpResponse::Ok().json(response)); + } + _ => { + return Err(ApiError::Request(eyre::eyre!( + "exactly one of `ids` and `project_ids` is required" + ))); + } }; let response = v3::versions::versions_get( req, diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index af7d2633d4..ceea429c5f 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -1092,6 +1092,140 @@ pub async fn version_list( .await } +/// Load the shared loader/game-version pairs used by automatic featuring. +pub(crate) async fn auto_featured_filters( + ro_pool: &ReadOnlyPgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let (loaders, game_versions) = futures::future::try_join( + database::models::loader_fields::Loader::list(&**ro_pool, redis), + database::models::legacy_loader_fields::MinecraftGameVersion::list( + None, + Some(true), + &**ro_pool, + redis, + ), + ) + .await + .wrap_internal_err("fetching automatic featured filters")?; + Ok(game_versions + .iter() + .flat_map(|game_version| { + loaders.iter().map(|loader| { + (game_version.version.clone(), loader.loader.clone()) + }) + }) + .collect()) +} + +/// Apply project-list filters, including the legacy automatic featured fallback. +pub(crate) async fn filter_project_versions( + versions: Vec, + filters: &VersionListFilters, + ro_pool: &ReadOnlyPgPool, + redis: &RedisPool, + auto_featured: Option<&[(String, String)]>, +) -> Result, ApiError> { + let loader_field_filters = filters.loader_fields.as_ref().map(|x| { + serde_json::from_str::>>(x) + .unwrap_or_default() + }); + let loader_filters = filters + .loaders + .as_ref() + .map(|x| serde_json::from_str::>(x).unwrap_or_default()); + let mut versions = versions + .into_iter() + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = filters.version_type { + bool &= &*x.inner.version_type == version_type.as_str(); + } + if let Some(loaders) = &loader_filters { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + if let Some(loader_fields) = &loader_field_filters { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; + } + } + bool + }) + .collect::>(); + + // Sort before applying limit/offset so that limit=N returns the N newest versions + versions + .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); + + let versions: Vec<_> = versions + .into_iter() + .skip(filters.offset.unwrap_or(0)) + .take(filters.limit.unwrap_or(usize::MAX)) + .collect(); + + let mut response = versions + .iter() + .filter(|version| { + filters + .featured + .is_none_or(|featured| featured == version.inner.featured) + }) + .cloned() + .collect::>(); + + // Attempt to populate versions with "auto featured" versions + if response.is_empty() + && !versions.is_empty() + && filters.featured.unwrap_or(false) + { + // TODO: This is a bandaid fix for detecting auto-featured versions. + // In the future, not all versions will have 'game_versions' fields, so this will need to be changed. + let loaded_filters; + let joined_filters = if let Some(filters) = auto_featured { + filters + } else { + loaded_filters = auto_featured_filters(ro_pool, redis).await?; + &loaded_filters + }; + + joined_filters.iter().for_each(|filter| { + if let Some(version) = versions.iter().find(|version| { + // TODO: This is the bandaid fix for detecting auto-featured versions. + let game_versions = version + .version_fields + .iter() + .find(|vf| vf.field_name == "game_versions") + .map(|vf| vf.value.clone()) + .map(|v| v.as_strings()) + .unwrap_or_default(); + game_versions.contains(&filter.0) + && version.loaders.contains(&filter.1) + }) { + response.push(version.clone()); + } + }); + + if response.is_empty() { + versions + .into_iter() + .for_each(|version| response.push(version)); + } + } + + response + .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); + response.dedup_by(|a, b| a.inner.id == b.inner.id); + + Ok(response) +} + pub async fn version_list_internal( req: HttpRequest, info: web::Path<(String,)>, @@ -1126,122 +1260,16 @@ pub async fn version_list_internal( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let loader_field_filters = filters.loader_fields.as_ref().map(|x| { - serde_json::from_str::>>(x) - .unwrap_or_default() - }); - let loader_filters = filters.loaders.as_ref().map(|x| { - serde_json::from_str::>(x).unwrap_or_default() - }); - let mut versions = database::models::DBVersion::get_many( + let versions = database::models::DBVersion::get_many( &project.versions, &***ro_pool, &redis, ) .await - .wrap_internal_err("fetching versions from database")? - .into_iter() - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = filters.version_type { - bool &= &*x.inner.version_type == version_type.as_str(); - } - if let Some(loaders) = &loader_filters { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(loader_fields) = &loader_field_filters { - for (key, values) in loader_fields { - bool &= if let Some(x_vf) = - x.version_fields.iter().find(|y| y.field_name == *key) - { - values.iter().any(|v| x_vf.value.contains_json_value(v)) - } else { - true - }; - } - } - bool - }) - .collect::>(); - - // Sort before applying limit/offset so that limit=N returns the N newest versions - versions.sort_by(|a, b| { - b.inner.date_published.cmp(&a.inner.date_published) - }); - - let versions: Vec<_> = versions - .into_iter() - .skip(filters.offset.unwrap_or(0)) - .take(filters.limit.unwrap_or(usize::MAX)) - .collect(); - - let mut response = versions - .iter() - .filter(|version| { - filters - .featured - .is_none_or(|featured| featured == version.inner.featured) - }) - .cloned() - .collect::>(); - - // Attempt to populate versions with "auto featured" versions - if response.is_empty() - && !versions.is_empty() - && filters.featured.unwrap_or(false) - { - // TODO: This is a bandaid fix for detecting auto-featured versions. - // In the future, not all versions will have 'game_versions' fields, so this will need to be changed. - let (loaders, game_versions) = futures::future::try_join( - database::models::loader_fields::Loader::list( - &***ro_pool, - &redis, - ), - database::models::legacy_loader_fields::MinecraftGameVersion::list( - None, - Some(true), - &***ro_pool, - &redis, - ), - ) - .await.wrap_internal_err("fetching minecraft game version from Redis")?; - - let mut joined_filters = Vec::new(); - for game_version in &game_versions { - for loader in &loaders { - joined_filters.push((game_version, loader)) - } - } - - joined_filters.into_iter().for_each(|filter| { - if let Some(version) = versions.iter().find(|version| { - // TODO: This is the bandaid fix for detecting auto-featured versions. - let game_versions = version - .version_fields - .iter() - .find(|vf| vf.field_name == "game_versions") - .map(|vf| vf.value.clone()) - .map(|v| v.as_strings()) - .unwrap_or_default(); - game_versions.contains(&filter.0.version) - && version.loaders.contains(&filter.1.loader) - }) { - response.push(version.clone()); - } - }); - - if response.is_empty() { - versions - .into_iter() - .for_each(|version| response.push(version)); - } - } - - response.sort_by(|a, b| { - b.inner.date_published.cmp(&a.inner.date_published) - }); - response.dedup_by(|a, b| a.inner.id == b.inner.id); + .wrap_internal_err("fetching versions from database")?; + let response = + filter_project_versions(versions, &filters, &ro_pool, &redis, None) + .await?; let mut response = filter_visible_versions(response, &user_option, &ro_pool, &redis) diff --git a/apps/labrinth/tests/v2/bulk_project_versions.rs b/apps/labrinth/tests/v2/bulk_project_versions.rs new file mode 100644 index 0000000000..b158a30d65 --- /dev/null +++ b/apps/labrinth/tests/v2/bulk_project_versions.rs @@ -0,0 +1,449 @@ +use crate::assert_status; +use crate::common::api_common::{ + Api, ApiProject, ApiVersion, AppendsOptionalPat, +}; +use crate::common::api_v2::ApiV2; +use crate::common::database::{ADMIN_USER_PAT, ENEMY_USER_PAT, USER_USER_PAT}; +use crate::common::dummy_data::TestFile; +use crate::common::environment::{TestEnvironment, with_test_environment}; +use actix_http::StatusCode; +use actix_web::test::{self, TestRequest}; +use labrinth::models::ids::ProjectId; +use serde_json::{Value, json}; +use std::collections::HashSet; + +fn query(parameters: &[(&str, Value)]) -> String { + let parameters = parameters + .iter() + .map(|(name, value)| { + let value = match value { + Value::String(value) => value.clone(), + value => value.to_string(), + }; + format!("{name}={}", urlencoding::encode(&value)) + }) + .collect::>() + .join("&"); + format!("/v2/versions?{parameters}") +} + +async fn versions( + api: &ApiV2, + parameters: &[(&str, Value)], + pat: Option<&str>, +) -> Vec { + let response = api + .call( + TestRequest::get() + .uri(&query(parameters)) + .append_pat(pat) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + test::read_body_json(response).await +} + +fn ids(versions: &[Value]) -> HashSet { + versions + .iter() + .map(|version| version["id"].as_str().unwrap().to_owned()) + .collect() +} + +async fn add_version( + api: &ApiV2, + project: ProjectId, + number: &str, + loader: &str, + mc: &str, +) -> String { + let patch = serde_json::from_value(json!([ + {"op": "replace", "path": "/loaders", "value": [loader]}, + {"op": "replace", "path": "/game_versions", "value": [mc]}, + {"op": "add", "path": "/changelog", "value": "bulk listing changelog"} + ])) + .unwrap(); + api.add_public_version_deserialized_common( + project, + number, + TestFile::build_random_jar(), + None, + Some(patch), + USER_USER_PAT, + ) + .await + .id + .to_string() +} + +#[actix_rt::test] +async fn bulk_project_versions_filter_each_project_without_collapsing_releases() +{ + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let response = api + .edit_project( + &beta.project_id, + json!({"status": "approved"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let mut expected = HashSet::new(); + let mut fabric = HashSet::new(); + for project in [alpha.project_id_parsed, beta.project_id_parsed] { + for (number, loader, mc) in [ + ("bulk-fabric-1", "fabric", "1.20.1"), + ("bulk-fabric-2", "fabric", "1.20.1"), + ("bulk-forge", "forge", "1.20.1"), + ("bulk-old", "fabric", "1.19.4"), + ] { + let id = add_version(api, project, number, loader, mc).await; + if mc == "1.20.1" { + if loader == "fabric" { + fabric.insert(id.clone()); + } + expected.insert(id); + } + } + } + for id in [&alpha.version_id, &beta.version_id] { + let response = api + .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + } + let selectors = json!([ + alpha.project_id, + alpha.project_slug, + beta.project_slug, + "missing-project" + ]); + let result = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("game_versions", json!(["1.20.1"])), + ("loaders", json!(["fabric", "forge"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 6); + assert_eq!(ids(&result), expected); + let result = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("game_versions", json!(["1.20.1"])), + ("loaders", json!(["fabric"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 4); + assert_eq!(ids(&result), fabric); + let result = versions( + api, + &[ + ("project_ids", selectors), + ("game_versions", json!(["1.19.4"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 2); + assert!(ids(&result).is_disjoint(&expected)); + }) + .await; +} + +#[actix_rt::test] +async fn bulk_project_versions_featured_modpacks_fall_back_to_matching_loader() { + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let (project, initial_versions) = api + .add_public_project( + "bulk-featured-modpack", + None, + None, + USER_USER_PAT, + ) + .await; + assert!(initial_versions.is_empty()); + + let mut fabric_id = None; + for (number, loader, featured) in [ + ("bulk-featured-forge", "forge", true), + ("bulk-unfeatured-fabric", "fabric", false), + ] { + let version = api + .add_public_version_deserialized_common( + project.id, + number, + TestFile::build_random_mrpack(), + None, + None, + USER_USER_PAT, + ) + .await; + let id = version.id.to_string(); + let response = api + .edit_version( + &id, + json!({ + "loaders": [loader], + "featured": featured, + "version_type": "release" + }), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + if loader == "fabric" { + fabric_id = Some(id); + } + } + + let result = versions( + api, + &[ + ("project_ids", json!([project.id])), + ("loaders", json!(["fabric"])), + ("featured", json!(true)), + ], + None, + ) + .await; + assert_eq!(result.len(), 1); + assert_eq!(ids(&result), HashSet::from([fabric_id.unwrap()])); + assert_eq!(result[0]["loaders"], json!(["fabric"])); + assert_eq!(result[0]["featured"], json!(false)); + assert_eq!(result[0]["version_type"], json!("release")); + }) + .await; +} + +#[actix_rt::test] +async fn bulk_project_versions_validate_selectors_and_preserve_ids_compatibility() + { + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let project = &env.dummy.project_alpha; + for parameters in [ + vec![], + vec![("ids", json!([])), ("project_ids", json!([]))], + vec![("project_ids", json!("not-json"))], + vec![("project_ids", json!({"id": project.project_id}))], + vec![("project_ids", json!([1]))], + vec![("project_ids", json!(vec![project.project_id.clone(); 101]))], + vec![("project_ids", json!([])), ("loaders", json!("not-json"))], + vec![("project_ids", json!([])), ("game_versions", json!([1]))], + vec![("project_ids", json!([])), ("loaders", json!({}))], + vec![ + ("project_ids", json!([])), + ("game_versions", json!("not-json")), + ], + ] { + let response = api + .call(TestRequest::get().uri(&query(¶meters)).to_request()) + .await; + assert_status!(&response, StatusCode::BAD_REQUEST); + } + assert!( + versions(api, &[("project_ids", json!([]))], None) + .await + .is_empty() + ); + let result = versions( + api, + &[("project_ids", json!(vec![project.project_id.clone(); 100]))], + None, + ) + .await; + assert_eq!(result.len(), 1); + assert_eq!(ids(&result), HashSet::from([project.version_id.clone()])); + let baseline = + versions(api, &[("ids", json!([project.version_id]))], None).await; + let filtered = versions( + api, + &[ + ("ids", json!([project.version_id])), + ("loaders", json!("not-json")), + ("game_versions", json!("not-json")), + ("featured", json!(false)), + ("version_type", json!("alpha")), + ("limit", json!(0)), + ("offset", json!(100)), + ], + None, + ) + .await; + assert_eq!(baseline, filtered); + assert_eq!(baseline.len(), 1); + }) + .await; +} + +#[actix_rt::test] +async fn bulk_project_versions_paginate_globally_without_a_default_limit() { + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let response = api + .edit_project( + &beta.project_id, + json!({"status": "approved"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let mut expected = HashSet::new(); + for index in 0..101 { + let project = if index % 2 == 0 { + alpha.project_id_parsed + } else { + beta.project_id_parsed + }; + expected.insert( + add_version( + api, + project, + &format!("bulk-page-{index}"), + "fabric", + "1.20.1", + ) + .await, + ); + } + for id in [&alpha.version_id, &beta.version_id] { + let response = api + .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + } + let selectors = json!([beta.project_id, alpha.project_id]); + let all = + versions(api, &[("project_ids", selectors.clone())], None).await; + assert_eq!(all.len(), 101); + assert_eq!(ids(&all), expected); + assert!( + all.windows(2).all(|pair| pair[0]["date_published"] + .as_str() + .unwrap() >= pair[1]["date_published"] + .as_str() + .unwrap()) + ); + assert!( + all.iter() + .all(|version| version["changelog"] == "bulk listing changelog") + ); + let page = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("offset", json!(1)), + ("limit", json!(3)), + ], + None, + ) + .await; + assert_eq!(page, all[1..4]); + let suffix = versions( + api, + &[("project_ids", selectors.clone()), ("offset", json!(99))], + None, + ) + .await; + assert_eq!(suffix, all[99..]); + for parameters in [ + vec![("project_ids", selectors.clone()), ("limit", json!(0))], + vec![("project_ids", selectors.clone()), ("offset", json!(101))], + ] { + assert!(versions(api, ¶meters, None).await.is_empty()); + } + for selector in ["project_ids", "ids"] { + let value = if selector == "ids" { + json!([all[0]["id"]]) + } else { + selectors.clone() + }; + let result = versions( + api, + &[(selector, value), ("include_changelog", json!(false))], + None, + ) + .await; + assert!(!result.is_empty()); + assert!(result.iter().all(|version| { + version.get("changelog").is_none_or(Value::is_null) + })); + } + }) + .await; +} + +#[actix_rt::test] +async fn bulk_project_versions_apply_visibility_before_global_pagination() { + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let hidden = add_version( + api, + alpha.project_id_parsed, + "bulk-hidden", + "fabric", + "1.20.1", + ) + .await; + let response = api + .edit_version(&hidden, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = api + .edit_project( + &beta.project_id, + json!({"status": "processing"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = api + .edit_version( + &beta.version_id, + json!({"status": "listed"}), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let selectors = json!([alpha.project_id, beta.project_id]); + for pat in [None, ENEMY_USER_PAT] { + let result = + versions(api, &[("project_ids", selectors.clone())], pat).await; + assert_eq!(ids(&result), HashSet::from([alpha.version_id.clone()])); + let page = versions( + api, + &[("project_ids", selectors.clone()), ("limit", json!(1))], + pat, + ) + .await; + assert_eq!(page, result); + } + let result = + versions(api, &[("project_ids", selectors)], USER_USER_PAT).await; + assert_eq!( + ids(&result), + HashSet::from([ + alpha.version_id.clone(), + beta.version_id.clone(), + hidden + ]) + ); + }) + .await; +} diff --git a/apps/labrinth/tests/v2_tests.rs b/apps/labrinth/tests/v2_tests.rs index f1fb05caef..82cf272d33 100644 --- a/apps/labrinth/tests/v2_tests.rs +++ b/apps/labrinth/tests/v2_tests.rs @@ -7,6 +7,7 @@ pub mod common; // Such V2 tests are exported here mod v2 { + mod bulk_project_versions; mod error; mod notifications; mod project; From 3797bca951a129515d7b6d0e4636affbb6a3c3bd Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:54:25 +0100 Subject: [PATCH 2/3] fmt --- apps/labrinth/src/auth/checks.rs | 6 +- apps/labrinth/src/routes/v2/versions.rs | 482 +++++------ apps/labrinth/src/routes/v3/versions.rs | 258 +++--- .../tests/v2/bulk_project_versions.rs | 793 +++++++++--------- apps/labrinth/tests/v2_tests.rs | 2 +- 5 files changed, 770 insertions(+), 771 deletions(-) diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index f83cb1d9ad..f0c132b20c 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -224,9 +224,9 @@ pub async fn filter_visible_versions( ) .await .wrap_api_err("filtering visible version ids")?; - let filtered_version_ids = filtered_version_ids - .into_iter() - .collect::>(); + let filtered_version_ids = filtered_version_ids + .into_iter() + .collect::>(); versions.retain(|x| filtered_version_ids.contains(&x.inner.id)); let missing = get_files_missing_attribution( diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index dba18da644..d38000d95f 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -44,60 +44,60 @@ pub struct VersionListFilters { } impl VersionListFilters { - fn into_v3(self) -> v3::versions::VersionListFilters { - let filters = self; - let loaders = if let Some(loaders) = filters.loaders { - if let Ok(mut loaders) = - serde_json::from_str::>(&loaders) - { - loaders.push("mrpack".to_string()); - Some(loaders) - } else { - None - } - } else { - None - }; + fn into_v3(self) -> v3::versions::VersionListFilters { + let filters = self; + let loaders = if let Some(loaders) = filters.loaders { + if let Ok(mut loaders) = + serde_json::from_str::>(&loaders) + { + loaders.push("mrpack".to_string()); + Some(loaders) + } else { + None + } + } else { + None + }; - let loader_fields = if let Some(game_versions) = filters.game_versions { - // TODO: extract this logic which is similar to the other v2->v3 version_file functions - let mut loader_fields = HashMap::new(); - serde_json::from_str::>(&game_versions) - .ok() - .and_then(|versions| { - let mut game_versions: Vec = vec![]; - for gv in versions { - game_versions.push(serde_json::json!(gv.clone())); - } - loader_fields - .insert("game_versions".to_string(), game_versions); + let loader_fields = if let Some(game_versions) = filters.game_versions { + // TODO: extract this logic which is similar to the other v2->v3 version_file functions + let mut loader_fields = HashMap::new(); + serde_json::from_str::>(&game_versions) + .ok() + .and_then(|versions| { + let mut game_versions: Vec = vec![]; + for gv in versions { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields + .insert("game_versions".to_string(), game_versions); - if let Some(ref loaders) = loaders { - loader_fields.insert( - "loaders".to_string(), - loaders - .iter() - .map(|x| serde_json::json!(x.clone())) - .collect(), - ); - } + if let Some(ref loaders) = loaders { + loader_fields.insert( + "loaders".to_string(), + loaders + .iter() + .map(|x| serde_json::json!(x.clone())) + .collect(), + ); + } - serde_json::to_string(&loader_fields).ok() - }) - } else { - None - }; + serde_json::to_string(&loader_fields).ok() + }) + } else { + None + }; - v3::versions::VersionListFilters { - loader_fields, - loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()), - featured: filters.featured, - version_type: filters.version_type, - limit: filters.limit, - offset: filters.offset, - include_changelog: filters.include_changelog, - } - } + v3::versions::VersionListFilters { + loader_fields, + loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()), + featured: filters.featured, + version_type: filters.version_type, + limit: filters.limit, + offset: filters.offset, + include_changelog: filters.include_changelog, + } + } } fn default_true() -> bool { @@ -151,7 +151,7 @@ pub async fn version_list( return Ok(response); } - let filters = filters.into_v3(); + let filters = filters.into_v3(); let response = v3::versions::version_list_internal( req, @@ -244,14 +244,14 @@ pub async fn version_project_get( #[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct VersionsFilters { - pub ids: Option, - pub project_ids: Option, - pub game_versions: Option, - pub loaders: Option, - pub featured: Option, - pub version_type: Option, - pub limit: Option, - pub offset: Option, + pub ids: Option, + pub project_ids: Option, + pub game_versions: Option, + pub loaders: Option, + pub featured: Option, + pub version_type: Option, + pub limit: Option, + pub offset: Option, #[serde(default = "default_true")] pub include_changelog: bool, } @@ -277,193 +277,193 @@ pub struct VersionsFilters { #[get("/versions")] pub async fn versions_get( req: HttpRequest, - web::Query(query): web::Query, + web::Query(query): web::Query, pool: web::Data, ro_pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = match (query.ids, query.project_ids) { - (Some(ids), None) => v3::versions::VersionIds { - ids, - include_changelog: query.include_changelog, - }, - (None, Some(project_ids)) => { - let filters = VersionListFilters { - game_versions: query.game_versions, - loaders: query.loaders, - featured: query - .featured - .map(|value| value.parse::()) - .transpose() - .wrap_request_err("deserializing `featured`")?, - version_type: query - .version_type - .map(|value| { - serde_json::from_value(serde_json::Value::String(value)) - }) - .transpose() - .wrap_request_err("deserializing `version_type`")?, - limit: query - .limit - .map(|value| value.parse::()) - .transpose() - .wrap_request_err("deserializing `limit`")?, - offset: query - .offset - .map(|value| value.parse::()) - .transpose() - .wrap_request_err("deserializing `offset`")?, - include_changelog: query.include_changelog, - }; - let project_ids = serde_json::from_str::>(&project_ids) - .wrap_request_err("deserializing `project_ids`")?; - if project_ids.len() > 100 { - return Err(ApiError::Request(eyre::eyre!( - "`project_ids` must contain at most 100 projects" - ))); - } - for (name, value) in [ - ("game_versions", &filters.game_versions), - ("loaders", &filters.loaders), - ] { - if let Some(value) = value { - serde_json::from_str::>(value) - .wrap_request_err_with(|| { - format!("deserializing `{name}`") - })?; - } - } - let requested_loaders = filters - .loaders - .as_deref() - .map(serde_json::from_str::>) - .transpose() - .wrap_request_err("deserializing `loaders`")?; - let user_option = crate::auth::get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::PROJECT_READ | Scopes::VERSION_READ, - ) - .await - .map(|x| x.1) - .ok(); - let mut projects = crate::database::models::DBProject::get_many( - &project_ids, - &***ro_pool, - &redis, - ) - .await - .wrap_internal_err("fetching projects from database")?; - let visible = crate::auth::checks::filter_visible_project_ids( - projects.iter().map(|p| &p.inner).collect(), - &user_option, - &pool, - false, - ) - .await - .wrap_api_err("filtering visible projects")?; - projects.retain(|p| visible.contains(&p.inner.id)); - let version_ids = projects - .iter() - .flat_map(|p| p.versions.iter().copied()) - .collect::>() - .into_iter() - .collect::>(); - let versions = crate::database::models::DBVersion::get_many( - &version_ids, - &***ro_pool, - &redis, - ) - .await - .wrap_internal_err("fetching versions from database")?; - let mut grouped = HashMap::new(); - for version in versions { - if let Some(loaders) = &requested_loaders { - let legacy_loaders = if version - .loaders - .iter() - .any(|loader| loader == "mrpack") - { - version - .version_fields - .iter() - .find(|field| field.field_name == "mrpack_loaders") - .map(|field| field.value.as_strings()) - .unwrap_or_else(|| version.loaders.clone()) - } else { - version.loaders.clone() - }; - if !legacy_loaders - .iter() - .any(|loader| loaders.contains(loader)) - { - continue; - } - } - grouped - .entry(version.inner.project_id) - .or_insert_with(Vec::new) - .push(version); - } - let mut filters = filters.into_v3(); - let limit = filters.limit.take(); - let offset = filters.offset.take(); - let auto_featured = if filters.featured == Some(true) { - Some( - v3::versions::auto_featured_filters(&ro_pool, &redis) - .await?, - ) - } else { - None - }; - let mut response = Vec::new(); - for mut versions in grouped.into_values() { - versions.sort_by_key(|version| version.inner.id.0); - response.extend( - v3::versions::filter_project_versions( - versions, - &filters, - &ro_pool, - &redis, - auto_featured.as_deref(), - ) - .await?, - ); - } - let mut response = crate::auth::checks::filter_visible_versions( - response, - &user_option, - &ro_pool, - &redis, - ) - .await - .wrap_api_err("filtering visible versions")?; - response.sort_by(|a, b| { - b.date_published - .cmp(&a.date_published) - .then_with(|| a.id.0.cmp(&b.id.0)) - }); - let response = response - .into_iter() - .map(|mut version| { - if !filters.include_changelog { - version.changelog = None; - } - LegacyVersion::from(version) - }) - .skip(offset.unwrap_or(0)) - .take(limit.unwrap_or(usize::MAX)) - .collect::>(); - return Ok(HttpResponse::Ok().json(response)); - } - _ => { - return Err(ApiError::Request(eyre::eyre!( - "exactly one of `ids` and `project_ids` is required" - ))); - } + let ids = match (query.ids, query.project_ids) { + (Some(ids), None) => v3::versions::VersionIds { + ids, + include_changelog: query.include_changelog, + }, + (None, Some(project_ids)) => { + let filters = VersionListFilters { + game_versions: query.game_versions, + loaders: query.loaders, + featured: query + .featured + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `featured`")?, + version_type: query + .version_type + .map(|value| { + serde_json::from_value(serde_json::Value::String(value)) + }) + .transpose() + .wrap_request_err("deserializing `version_type`")?, + limit: query + .limit + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `limit`")?, + offset: query + .offset + .map(|value| value.parse::()) + .transpose() + .wrap_request_err("deserializing `offset`")?, + include_changelog: query.include_changelog, + }; + let project_ids = serde_json::from_str::>(&project_ids) + .wrap_request_err("deserializing `project_ids`")?; + if project_ids.len() > 100 { + return Err(ApiError::Request(eyre::eyre!( + "`project_ids` must contain at most 100 projects" + ))); + } + for (name, value) in [ + ("game_versions", &filters.game_versions), + ("loaders", &filters.loaders), + ] { + if let Some(value) = value { + serde_json::from_str::>(value) + .wrap_request_err_with(|| { + format!("deserializing `{name}`") + })?; + } + } + let requested_loaders = filters + .loaders + .as_deref() + .map(serde_json::from_str::>) + .transpose() + .wrap_request_err("deserializing `loaders`")?; + let user_option = crate::auth::get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ | Scopes::VERSION_READ, + ) + .await + .map(|x| x.1) + .ok(); + let mut projects = crate::database::models::DBProject::get_many( + &project_ids, + &***ro_pool, + &redis, + ) + .await + .wrap_internal_err("fetching projects from database")?; + let visible = crate::auth::checks::filter_visible_project_ids( + projects.iter().map(|p| &p.inner).collect(), + &user_option, + &pool, + false, + ) + .await + .wrap_api_err("filtering visible projects")?; + projects.retain(|p| visible.contains(&p.inner.id)); + let version_ids = projects + .iter() + .flat_map(|p| p.versions.iter().copied()) + .collect::>() + .into_iter() + .collect::>(); + let versions = crate::database::models::DBVersion::get_many( + &version_ids, + &***ro_pool, + &redis, + ) + .await + .wrap_internal_err("fetching versions from database")?; + let mut grouped = HashMap::new(); + for version in versions { + if let Some(loaders) = &requested_loaders { + let legacy_loaders = if version + .loaders + .iter() + .any(|loader| loader == "mrpack") + { + version + .version_fields + .iter() + .find(|field| field.field_name == "mrpack_loaders") + .map(|field| field.value.as_strings()) + .unwrap_or_else(|| version.loaders.clone()) + } else { + version.loaders.clone() + }; + if !legacy_loaders + .iter() + .any(|loader| loaders.contains(loader)) + { + continue; + } + } + grouped + .entry(version.inner.project_id) + .or_insert_with(Vec::new) + .push(version); + } + let mut filters = filters.into_v3(); + let limit = filters.limit.take(); + let offset = filters.offset.take(); + let auto_featured = if filters.featured == Some(true) { + Some( + v3::versions::auto_featured_filters(&ro_pool, &redis) + .await?, + ) + } else { + None + }; + let mut response = Vec::new(); + for mut versions in grouped.into_values() { + versions.sort_by_key(|version| version.inner.id.0); + response.extend( + v3::versions::filter_project_versions( + versions, + &filters, + &ro_pool, + &redis, + auto_featured.as_deref(), + ) + .await?, + ); + } + let mut response = crate::auth::checks::filter_visible_versions( + response, + &user_option, + &ro_pool, + &redis, + ) + .await + .wrap_api_err("filtering visible versions")?; + response.sort_by(|a, b| { + b.date_published + .cmp(&a.date_published) + .then_with(|| a.id.0.cmp(&b.id.0)) + }); + let response = response + .into_iter() + .map(|mut version| { + if !filters.include_changelog { + version.changelog = None; + } + LegacyVersion::from(version) + }) + .skip(offset.unwrap_or(0)) + .take(limit.unwrap_or(usize::MAX)) + .collect::>(); + return Ok(HttpResponse::Ok().json(response)); + } + _ => { + return Err(ApiError::Request(eyre::eyre!( + "exactly one of `ids` and `project_ids` is required" + ))); + } }; let response = v3::versions::versions_get( req, diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index ceea429c5f..680c24bd8d 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -1094,136 +1094,136 @@ pub async fn version_list( /// Load the shared loader/game-version pairs used by automatic featuring. pub(crate) async fn auto_featured_filters( - ro_pool: &ReadOnlyPgPool, - redis: &RedisPool, + ro_pool: &ReadOnlyPgPool, + redis: &RedisPool, ) -> Result, ApiError> { - let (loaders, game_versions) = futures::future::try_join( - database::models::loader_fields::Loader::list(&**ro_pool, redis), - database::models::legacy_loader_fields::MinecraftGameVersion::list( - None, - Some(true), - &**ro_pool, - redis, - ), - ) - .await - .wrap_internal_err("fetching automatic featured filters")?; - Ok(game_versions - .iter() - .flat_map(|game_version| { - loaders.iter().map(|loader| { - (game_version.version.clone(), loader.loader.clone()) - }) - }) - .collect()) + let (loaders, game_versions) = futures::future::try_join( + database::models::loader_fields::Loader::list(&**ro_pool, redis), + database::models::legacy_loader_fields::MinecraftGameVersion::list( + None, + Some(true), + &**ro_pool, + redis, + ), + ) + .await + .wrap_internal_err("fetching automatic featured filters")?; + Ok(game_versions + .iter() + .flat_map(|game_version| { + loaders.iter().map(|loader| { + (game_version.version.clone(), loader.loader.clone()) + }) + }) + .collect()) } /// Apply project-list filters, including the legacy automatic featured fallback. pub(crate) async fn filter_project_versions( - versions: Vec, - filters: &VersionListFilters, - ro_pool: &ReadOnlyPgPool, - redis: &RedisPool, - auto_featured: Option<&[(String, String)]>, + versions: Vec, + filters: &VersionListFilters, + ro_pool: &ReadOnlyPgPool, + redis: &RedisPool, + auto_featured: Option<&[(String, String)]>, ) -> Result, ApiError> { - let loader_field_filters = filters.loader_fields.as_ref().map(|x| { - serde_json::from_str::>>(x) - .unwrap_or_default() - }); - let loader_filters = filters - .loaders - .as_ref() - .map(|x| serde_json::from_str::>(x).unwrap_or_default()); - let mut versions = versions - .into_iter() - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = filters.version_type { - bool &= &*x.inner.version_type == version_type.as_str(); - } - if let Some(loaders) = &loader_filters { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(loader_fields) = &loader_field_filters { - for (key, values) in loader_fields { - bool &= if let Some(x_vf) = - x.version_fields.iter().find(|y| y.field_name == *key) - { - values.iter().any(|v| x_vf.value.contains_json_value(v)) - } else { - true - }; - } - } - bool - }) - .collect::>(); - - // Sort before applying limit/offset so that limit=N returns the N newest versions - versions - .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); - - let versions: Vec<_> = versions - .into_iter() - .skip(filters.offset.unwrap_or(0)) - .take(filters.limit.unwrap_or(usize::MAX)) - .collect(); - - let mut response = versions - .iter() - .filter(|version| { - filters - .featured - .is_none_or(|featured| featured == version.inner.featured) - }) - .cloned() - .collect::>(); - - // Attempt to populate versions with "auto featured" versions - if response.is_empty() - && !versions.is_empty() - && filters.featured.unwrap_or(false) - { - // TODO: This is a bandaid fix for detecting auto-featured versions. - // In the future, not all versions will have 'game_versions' fields, so this will need to be changed. - let loaded_filters; - let joined_filters = if let Some(filters) = auto_featured { - filters - } else { - loaded_filters = auto_featured_filters(ro_pool, redis).await?; - &loaded_filters - }; - - joined_filters.iter().for_each(|filter| { - if let Some(version) = versions.iter().find(|version| { - // TODO: This is the bandaid fix for detecting auto-featured versions. - let game_versions = version - .version_fields - .iter() - .find(|vf| vf.field_name == "game_versions") - .map(|vf| vf.value.clone()) - .map(|v| v.as_strings()) - .unwrap_or_default(); - game_versions.contains(&filter.0) - && version.loaders.contains(&filter.1) - }) { - response.push(version.clone()); - } - }); - - if response.is_empty() { - versions - .into_iter() - .for_each(|version| response.push(version)); - } - } - - response - .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); - response.dedup_by(|a, b| a.inner.id == b.inner.id); - - Ok(response) + let loader_field_filters = filters.loader_fields.as_ref().map(|x| { + serde_json::from_str::>>(x) + .unwrap_or_default() + }); + let loader_filters = filters + .loaders + .as_ref() + .map(|x| serde_json::from_str::>(x).unwrap_or_default()); + let mut versions = versions + .into_iter() + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = filters.version_type { + bool &= &*x.inner.version_type == version_type.as_str(); + } + if let Some(loaders) = &loader_filters { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + if let Some(loader_fields) = &loader_field_filters { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; + } + } + bool + }) + .collect::>(); + + // Sort before applying limit/offset so that limit=N returns the N newest versions + versions + .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); + + let versions: Vec<_> = versions + .into_iter() + .skip(filters.offset.unwrap_or(0)) + .take(filters.limit.unwrap_or(usize::MAX)) + .collect(); + + let mut response = versions + .iter() + .filter(|version| { + filters + .featured + .is_none_or(|featured| featured == version.inner.featured) + }) + .cloned() + .collect::>(); + + // Attempt to populate versions with "auto featured" versions + if response.is_empty() + && !versions.is_empty() + && filters.featured.unwrap_or(false) + { + // TODO: This is a bandaid fix for detecting auto-featured versions. + // In the future, not all versions will have 'game_versions' fields, so this will need to be changed. + let loaded_filters; + let joined_filters = if let Some(filters) = auto_featured { + filters + } else { + loaded_filters = auto_featured_filters(ro_pool, redis).await?; + &loaded_filters + }; + + joined_filters.iter().for_each(|filter| { + if let Some(version) = versions.iter().find(|version| { + // TODO: This is the bandaid fix for detecting auto-featured versions. + let game_versions = version + .version_fields + .iter() + .find(|vf| vf.field_name == "game_versions") + .map(|vf| vf.value.clone()) + .map(|v| v.as_strings()) + .unwrap_or_default(); + game_versions.contains(&filter.0) + && version.loaders.contains(&filter.1) + }) { + response.push(version.clone()); + } + }); + + if response.is_empty() { + versions + .into_iter() + .for_each(|version| response.push(version)); + } + } + + response + .sort_by_key(|version| std::cmp::Reverse(version.inner.date_published)); + response.dedup_by(|a, b| a.inner.id == b.inner.id); + + Ok(response) } pub async fn version_list_internal( @@ -1260,16 +1260,16 @@ pub async fn version_list_internal( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let versions = database::models::DBVersion::get_many( + let versions = database::models::DBVersion::get_many( &project.versions, &***ro_pool, &redis, ) .await - .wrap_internal_err("fetching versions from database")?; - let response = - filter_project_versions(versions, &filters, &ro_pool, &redis, None) - .await?; + .wrap_internal_err("fetching versions from database")?; + let response = + filter_project_versions(versions, &filters, &ro_pool, &redis, None) + .await?; let mut response = filter_visible_versions(response, &user_option, &ro_pool, &redis) diff --git a/apps/labrinth/tests/v2/bulk_project_versions.rs b/apps/labrinth/tests/v2/bulk_project_versions.rs index b158a30d65..e4c268a6b1 100644 --- a/apps/labrinth/tests/v2/bulk_project_versions.rs +++ b/apps/labrinth/tests/v2/bulk_project_versions.rs @@ -1,6 +1,6 @@ use crate::assert_status; use crate::common::api_common::{ - Api, ApiProject, ApiVersion, AppendsOptionalPat, + Api, ApiProject, ApiVersion, AppendsOptionalPat, }; use crate::common::api_v2::ApiV2; use crate::common::database::{ADMIN_USER_PAT, ENEMY_USER_PAT, USER_USER_PAT}; @@ -13,437 +13,436 @@ use serde_json::{Value, json}; use std::collections::HashSet; fn query(parameters: &[(&str, Value)]) -> String { - let parameters = parameters - .iter() - .map(|(name, value)| { - let value = match value { - Value::String(value) => value.clone(), - value => value.to_string(), - }; - format!("{name}={}", urlencoding::encode(&value)) - }) - .collect::>() - .join("&"); - format!("/v2/versions?{parameters}") + let parameters = parameters + .iter() + .map(|(name, value)| { + let value = match value { + Value::String(value) => value.clone(), + value => value.to_string(), + }; + format!("{name}={}", urlencoding::encode(&value)) + }) + .collect::>() + .join("&"); + format!("/v2/versions?{parameters}") } async fn versions( - api: &ApiV2, - parameters: &[(&str, Value)], - pat: Option<&str>, + api: &ApiV2, + parameters: &[(&str, Value)], + pat: Option<&str>, ) -> Vec { - let response = api - .call( - TestRequest::get() - .uri(&query(parameters)) - .append_pat(pat) - .to_request(), - ) - .await; - assert_status!(&response, StatusCode::OK); - test::read_body_json(response).await + let response = api + .call( + TestRequest::get() + .uri(&query(parameters)) + .append_pat(pat) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + test::read_body_json(response).await } fn ids(versions: &[Value]) -> HashSet { - versions - .iter() - .map(|version| version["id"].as_str().unwrap().to_owned()) - .collect() + versions + .iter() + .map(|version| version["id"].as_str().unwrap().to_owned()) + .collect() } async fn add_version( - api: &ApiV2, - project: ProjectId, - number: &str, - loader: &str, - mc: &str, + api: &ApiV2, + project: ProjectId, + number: &str, + loader: &str, + mc: &str, ) -> String { - let patch = serde_json::from_value(json!([ - {"op": "replace", "path": "/loaders", "value": [loader]}, - {"op": "replace", "path": "/game_versions", "value": [mc]}, - {"op": "add", "path": "/changelog", "value": "bulk listing changelog"} - ])) - .unwrap(); - api.add_public_version_deserialized_common( - project, - number, - TestFile::build_random_jar(), - None, - Some(patch), - USER_USER_PAT, - ) - .await - .id - .to_string() + let patch = serde_json::from_value(json!([ + {"op": "replace", "path": "/loaders", "value": [loader]}, + {"op": "replace", "path": "/game_versions", "value": [mc]}, + {"op": "add", "path": "/changelog", "value": "bulk listing changelog"} + ])) + .unwrap(); + api.add_public_version_deserialized_common( + project, + number, + TestFile::build_random_jar(), + None, + Some(patch), + USER_USER_PAT, + ) + .await + .id + .to_string() } #[actix_rt::test] async fn bulk_project_versions_filter_each_project_without_collapsing_releases() { - with_test_environment(None, |env: TestEnvironment| async move { - let api = &env.api; - let alpha = &env.dummy.project_alpha; - let beta = &env.dummy.project_beta; - let response = api - .edit_project( - &beta.project_id, - json!({"status": "approved"}), - ADMIN_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - let mut expected = HashSet::new(); - let mut fabric = HashSet::new(); - for project in [alpha.project_id_parsed, beta.project_id_parsed] { - for (number, loader, mc) in [ - ("bulk-fabric-1", "fabric", "1.20.1"), - ("bulk-fabric-2", "fabric", "1.20.1"), - ("bulk-forge", "forge", "1.20.1"), - ("bulk-old", "fabric", "1.19.4"), - ] { - let id = add_version(api, project, number, loader, mc).await; - if mc == "1.20.1" { - if loader == "fabric" { - fabric.insert(id.clone()); - } - expected.insert(id); - } - } - } - for id in [&alpha.version_id, &beta.version_id] { - let response = api - .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - } - let selectors = json!([ - alpha.project_id, - alpha.project_slug, - beta.project_slug, - "missing-project" - ]); - let result = versions( - api, - &[ - ("project_ids", selectors.clone()), - ("game_versions", json!(["1.20.1"])), - ("loaders", json!(["fabric", "forge"])), - ], - None, - ) - .await; - assert_eq!(result.len(), 6); - assert_eq!(ids(&result), expected); - let result = versions( - api, - &[ - ("project_ids", selectors.clone()), - ("game_versions", json!(["1.20.1"])), - ("loaders", json!(["fabric"])), - ], - None, - ) - .await; - assert_eq!(result.len(), 4); - assert_eq!(ids(&result), fabric); - let result = versions( - api, - &[ - ("project_ids", selectors), - ("game_versions", json!(["1.19.4"])), - ], - None, - ) - .await; - assert_eq!(result.len(), 2); - assert!(ids(&result).is_disjoint(&expected)); - }) - .await; + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let response = api + .edit_project( + &beta.project_id, + json!({"status": "approved"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let mut expected = HashSet::new(); + let mut fabric = HashSet::new(); + for project in [alpha.project_id_parsed, beta.project_id_parsed] { + for (number, loader, mc) in [ + ("bulk-fabric-1", "fabric", "1.20.1"), + ("bulk-fabric-2", "fabric", "1.20.1"), + ("bulk-forge", "forge", "1.20.1"), + ("bulk-old", "fabric", "1.19.4"), + ] { + let id = add_version(api, project, number, loader, mc).await; + if mc == "1.20.1" { + if loader == "fabric" { + fabric.insert(id.clone()); + } + expected.insert(id); + } + } + } + for id in [&alpha.version_id, &beta.version_id] { + let response = api + .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + } + let selectors = json!([ + alpha.project_id, + alpha.project_slug, + beta.project_slug, + "missing-project" + ]); + let result = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("game_versions", json!(["1.20.1"])), + ("loaders", json!(["fabric", "forge"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 6); + assert_eq!(ids(&result), expected); + let result = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("game_versions", json!(["1.20.1"])), + ("loaders", json!(["fabric"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 4); + assert_eq!(ids(&result), fabric); + let result = versions( + api, + &[ + ("project_ids", selectors), + ("game_versions", json!(["1.19.4"])), + ], + None, + ) + .await; + assert_eq!(result.len(), 2); + assert!(ids(&result).is_disjoint(&expected)); + }) + .await; } #[actix_rt::test] -async fn bulk_project_versions_featured_modpacks_fall_back_to_matching_loader() { - with_test_environment(None, |env: TestEnvironment| async move { - let api = &env.api; - let (project, initial_versions) = api - .add_public_project( - "bulk-featured-modpack", - None, - None, - USER_USER_PAT, - ) - .await; - assert!(initial_versions.is_empty()); +async fn bulk_project_versions_featured_modpacks_fall_back_to_matching_loader() +{ + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let (project, initial_versions) = api + .add_public_project( + "bulk-featured-modpack", + None, + None, + USER_USER_PAT, + ) + .await; + assert!(initial_versions.is_empty()); - let mut fabric_id = None; - for (number, loader, featured) in [ - ("bulk-featured-forge", "forge", true), - ("bulk-unfeatured-fabric", "fabric", false), - ] { - let version = api - .add_public_version_deserialized_common( - project.id, - number, - TestFile::build_random_mrpack(), - None, - None, - USER_USER_PAT, - ) - .await; - let id = version.id.to_string(); - let response = api - .edit_version( - &id, - json!({ - "loaders": [loader], - "featured": featured, - "version_type": "release" - }), - USER_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - if loader == "fabric" { - fabric_id = Some(id); - } - } + let mut fabric_id = None; + for (number, loader, featured) in [ + ("bulk-featured-forge", "forge", true), + ("bulk-unfeatured-fabric", "fabric", false), + ] { + let version = api + .add_public_version_deserialized_common( + project.id, + number, + TestFile::build_random_mrpack(), + None, + None, + USER_USER_PAT, + ) + .await; + let id = version.id.to_string(); + let response = api + .edit_version( + &id, + json!({ + "loaders": [loader], + "featured": featured, + "version_type": "release" + }), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + if loader == "fabric" { + fabric_id = Some(id); + } + } - let result = versions( - api, - &[ - ("project_ids", json!([project.id])), - ("loaders", json!(["fabric"])), - ("featured", json!(true)), - ], - None, - ) - .await; - assert_eq!(result.len(), 1); - assert_eq!(ids(&result), HashSet::from([fabric_id.unwrap()])); - assert_eq!(result[0]["loaders"], json!(["fabric"])); - assert_eq!(result[0]["featured"], json!(false)); - assert_eq!(result[0]["version_type"], json!("release")); - }) - .await; + let result = versions( + api, + &[ + ("project_ids", json!([project.id])), + ("loaders", json!(["fabric"])), + ("featured", json!(true)), + ], + None, + ) + .await; + assert_eq!(result.len(), 1); + assert_eq!(ids(&result), HashSet::from([fabric_id.unwrap()])); + assert_eq!(result[0]["loaders"], json!(["fabric"])); + assert_eq!(result[0]["featured"], json!(false)); + assert_eq!(result[0]["version_type"], json!("release")); + }) + .await; } #[actix_rt::test] async fn bulk_project_versions_validate_selectors_and_preserve_ids_compatibility() { - with_test_environment(None, |env: TestEnvironment| async move { - let api = &env.api; - let project = &env.dummy.project_alpha; - for parameters in [ - vec![], - vec![("ids", json!([])), ("project_ids", json!([]))], - vec![("project_ids", json!("not-json"))], - vec![("project_ids", json!({"id": project.project_id}))], - vec![("project_ids", json!([1]))], - vec![("project_ids", json!(vec![project.project_id.clone(); 101]))], - vec![("project_ids", json!([])), ("loaders", json!("not-json"))], - vec![("project_ids", json!([])), ("game_versions", json!([1]))], - vec![("project_ids", json!([])), ("loaders", json!({}))], - vec![ - ("project_ids", json!([])), - ("game_versions", json!("not-json")), - ], - ] { - let response = api - .call(TestRequest::get().uri(&query(¶meters)).to_request()) - .await; - assert_status!(&response, StatusCode::BAD_REQUEST); - } - assert!( - versions(api, &[("project_ids", json!([]))], None) - .await - .is_empty() - ); - let result = versions( - api, - &[("project_ids", json!(vec![project.project_id.clone(); 100]))], - None, - ) - .await; - assert_eq!(result.len(), 1); - assert_eq!(ids(&result), HashSet::from([project.version_id.clone()])); - let baseline = - versions(api, &[("ids", json!([project.version_id]))], None).await; - let filtered = versions( - api, - &[ - ("ids", json!([project.version_id])), - ("loaders", json!("not-json")), - ("game_versions", json!("not-json")), - ("featured", json!(false)), - ("version_type", json!("alpha")), - ("limit", json!(0)), - ("offset", json!(100)), - ], - None, - ) - .await; - assert_eq!(baseline, filtered); - assert_eq!(baseline.len(), 1); - }) - .await; + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let project = &env.dummy.project_alpha; + for parameters in [ + vec![], + vec![("ids", json!([])), ("project_ids", json!([]))], + vec![("project_ids", json!("not-json"))], + vec![("project_ids", json!({"id": project.project_id}))], + vec![("project_ids", json!([1]))], + vec![("project_ids", json!(vec![project.project_id.clone(); 101]))], + vec![("project_ids", json!([])), ("loaders", json!("not-json"))], + vec![("project_ids", json!([])), ("game_versions", json!([1]))], + vec![("project_ids", json!([])), ("loaders", json!({}))], + vec![ + ("project_ids", json!([])), + ("game_versions", json!("not-json")), + ], + ] { + let response = api + .call(TestRequest::get().uri(&query(¶meters)).to_request()) + .await; + assert_status!(&response, StatusCode::BAD_REQUEST); + } + assert!( + versions(api, &[("project_ids", json!([]))], None) + .await + .is_empty() + ); + let result = versions( + api, + &[("project_ids", json!(vec![project.project_id.clone(); 100]))], + None, + ) + .await; + assert_eq!(result.len(), 1); + assert_eq!(ids(&result), HashSet::from([project.version_id.clone()])); + let baseline = + versions(api, &[("ids", json!([project.version_id]))], None).await; + let filtered = versions( + api, + &[ + ("ids", json!([project.version_id])), + ("loaders", json!("not-json")), + ("game_versions", json!("not-json")), + ("featured", json!(false)), + ("version_type", json!("alpha")), + ("limit", json!(0)), + ("offset", json!(100)), + ], + None, + ) + .await; + assert_eq!(baseline, filtered); + assert_eq!(baseline.len(), 1); + }) + .await; } #[actix_rt::test] async fn bulk_project_versions_paginate_globally_without_a_default_limit() { - with_test_environment(None, |env: TestEnvironment| async move { - let api = &env.api; - let alpha = &env.dummy.project_alpha; - let beta = &env.dummy.project_beta; - let response = api - .edit_project( - &beta.project_id, - json!({"status": "approved"}), - ADMIN_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - let mut expected = HashSet::new(); - for index in 0..101 { - let project = if index % 2 == 0 { - alpha.project_id_parsed - } else { - beta.project_id_parsed - }; - expected.insert( - add_version( - api, - project, - &format!("bulk-page-{index}"), - "fabric", - "1.20.1", - ) - .await, - ); - } - for id in [&alpha.version_id, &beta.version_id] { - let response = api - .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - } - let selectors = json!([beta.project_id, alpha.project_id]); - let all = - versions(api, &[("project_ids", selectors.clone())], None).await; - assert_eq!(all.len(), 101); - assert_eq!(ids(&all), expected); - assert!( - all.windows(2).all(|pair| pair[0]["date_published"] - .as_str() - .unwrap() >= pair[1]["date_published"] - .as_str() - .unwrap()) - ); - assert!( + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let response = api + .edit_project( + &beta.project_id, + json!({"status": "approved"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let mut expected = HashSet::new(); + for index in 0..101 { + let project = if index % 2 == 0 { + alpha.project_id_parsed + } else { + beta.project_id_parsed + }; + expected.insert( + add_version( + api, + project, + &format!("bulk-page-{index}"), + "fabric", + "1.20.1", + ) + .await, + ); + } + for id in [&alpha.version_id, &beta.version_id] { + let response = api + .edit_version(id, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + } + let selectors = json!([beta.project_id, alpha.project_id]); + let all = + versions(api, &[("project_ids", selectors.clone())], None).await; + assert_eq!(all.len(), 101); + assert_eq!(ids(&all), expected); + assert!( + all.windows(2) + .all(|pair| pair[0]["date_published"].as_str().unwrap() + >= pair[1]["date_published"].as_str().unwrap()) + ); + assert!( all.iter() .all(|version| version["changelog"] == "bulk listing changelog") ); - let page = versions( - api, - &[ - ("project_ids", selectors.clone()), - ("offset", json!(1)), - ("limit", json!(3)), - ], - None, - ) - .await; - assert_eq!(page, all[1..4]); - let suffix = versions( - api, - &[("project_ids", selectors.clone()), ("offset", json!(99))], - None, - ) - .await; - assert_eq!(suffix, all[99..]); - for parameters in [ - vec![("project_ids", selectors.clone()), ("limit", json!(0))], - vec![("project_ids", selectors.clone()), ("offset", json!(101))], - ] { - assert!(versions(api, ¶meters, None).await.is_empty()); - } - for selector in ["project_ids", "ids"] { - let value = if selector == "ids" { - json!([all[0]["id"]]) - } else { - selectors.clone() - }; - let result = versions( - api, - &[(selector, value), ("include_changelog", json!(false))], - None, - ) - .await; - assert!(!result.is_empty()); - assert!(result.iter().all(|version| { - version.get("changelog").is_none_or(Value::is_null) - })); - } - }) - .await; + let page = versions( + api, + &[ + ("project_ids", selectors.clone()), + ("offset", json!(1)), + ("limit", json!(3)), + ], + None, + ) + .await; + assert_eq!(page, all[1..4]); + let suffix = versions( + api, + &[("project_ids", selectors.clone()), ("offset", json!(99))], + None, + ) + .await; + assert_eq!(suffix, all[99..]); + for parameters in [ + vec![("project_ids", selectors.clone()), ("limit", json!(0))], + vec![("project_ids", selectors.clone()), ("offset", json!(101))], + ] { + assert!(versions(api, ¶meters, None).await.is_empty()); + } + for selector in ["project_ids", "ids"] { + let value = if selector == "ids" { + json!([all[0]["id"]]) + } else { + selectors.clone() + }; + let result = versions( + api, + &[(selector, value), ("include_changelog", json!(false))], + None, + ) + .await; + assert!(!result.is_empty()); + assert!(result.iter().all(|version| { + version.get("changelog").is_none_or(Value::is_null) + })); + } + }) + .await; } #[actix_rt::test] async fn bulk_project_versions_apply_visibility_before_global_pagination() { - with_test_environment(None, |env: TestEnvironment| async move { - let api = &env.api; - let alpha = &env.dummy.project_alpha; - let beta = &env.dummy.project_beta; - let hidden = add_version( - api, - alpha.project_id_parsed, - "bulk-hidden", - "fabric", - "1.20.1", - ) - .await; - let response = api - .edit_version(&hidden, json!({"status": "draft"}), USER_USER_PAT) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - let response = api - .edit_project( - &beta.project_id, - json!({"status": "processing"}), - ADMIN_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - let response = api - .edit_version( - &beta.version_id, - json!({"status": "listed"}), - USER_USER_PAT, - ) - .await; - assert_status!(&response, StatusCode::NO_CONTENT); - let selectors = json!([alpha.project_id, beta.project_id]); - for pat in [None, ENEMY_USER_PAT] { - let result = - versions(api, &[("project_ids", selectors.clone())], pat).await; - assert_eq!(ids(&result), HashSet::from([alpha.version_id.clone()])); - let page = versions( - api, - &[("project_ids", selectors.clone()), ("limit", json!(1))], - pat, - ) - .await; - assert_eq!(page, result); - } - let result = - versions(api, &[("project_ids", selectors)], USER_USER_PAT).await; - assert_eq!( - ids(&result), - HashSet::from([ - alpha.version_id.clone(), - beta.version_id.clone(), - hidden - ]) - ); - }) - .await; + with_test_environment(None, |env: TestEnvironment| async move { + let api = &env.api; + let alpha = &env.dummy.project_alpha; + let beta = &env.dummy.project_beta; + let hidden = add_version( + api, + alpha.project_id_parsed, + "bulk-hidden", + "fabric", + "1.20.1", + ) + .await; + let response = api + .edit_version(&hidden, json!({"status": "draft"}), USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = api + .edit_project( + &beta.project_id, + json!({"status": "processing"}), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = api + .edit_version( + &beta.version_id, + json!({"status": "listed"}), + USER_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let selectors = json!([alpha.project_id, beta.project_id]); + for pat in [None, ENEMY_USER_PAT] { + let result = + versions(api, &[("project_ids", selectors.clone())], pat).await; + assert_eq!(ids(&result), HashSet::from([alpha.version_id.clone()])); + let page = versions( + api, + &[("project_ids", selectors.clone()), ("limit", json!(1))], + pat, + ) + .await; + assert_eq!(page, result); + } + let result = + versions(api, &[("project_ids", selectors)], USER_USER_PAT).await; + assert_eq!( + ids(&result), + HashSet::from([ + alpha.version_id.clone(), + beta.version_id.clone(), + hidden + ]) + ); + }) + .await; } diff --git a/apps/labrinth/tests/v2_tests.rs b/apps/labrinth/tests/v2_tests.rs index 82cf272d33..ea188117a4 100644 --- a/apps/labrinth/tests/v2_tests.rs +++ b/apps/labrinth/tests/v2_tests.rs @@ -7,7 +7,7 @@ pub mod common; // Such V2 tests are exported here mod v2 { - mod bulk_project_versions; + mod bulk_project_versions; mod error; mod notifications; mod project; From 5ec5c623fe24af19753d08a3cefa2a367730f626 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:01:28 +0100 Subject: [PATCH 3/3] fix up tests --- apps/labrinth/src/routes/v2/versions.rs | 16 ++++++++++++---- apps/labrinth/tests/v2/bulk_project_versions.rs | 7 ++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index d38000d95f..2e2c410fd2 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -366,12 +366,20 @@ pub async fn versions_get( .await .wrap_api_err("filtering visible projects")?; projects.retain(|p| visible.contains(&p.inner.id)); - let version_ids = projects + let project_ids = projects .iter() - .flat_map(|p| p.versions.iter().copied()) - .collect::>() - .into_iter() + .map(|project| project.inner.id.0) .collect::>(); + let version_ids = sqlx::query!( + r#"SELECT id FROM versions WHERE mod_id = ANY($1::bigint[]) ORDER BY id"#, + &project_ids + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("fetching project version ids from database")? + .into_iter() + .map(|version| crate::database::models::ids::DBVersionId(version.id)) + .collect::>(); let versions = crate::database::models::DBVersion::get_many( &version_ids, &***ro_pool, diff --git a/apps/labrinth/tests/v2/bulk_project_versions.rs b/apps/labrinth/tests/v2/bulk_project_versions.rs index e4c268a6b1..8f8fbfcd1c 100644 --- a/apps/labrinth/tests/v2/bulk_project_versions.rs +++ b/apps/labrinth/tests/v2/bulk_project_versions.rs @@ -99,7 +99,7 @@ async fn bulk_project_versions_filter_each_project_without_collapsing_releases() ("bulk-fabric-1", "fabric", "1.20.1"), ("bulk-fabric-2", "fabric", "1.20.1"), ("bulk-forge", "forge", "1.20.1"), - ("bulk-old", "fabric", "1.19.4"), + ("bulk-other-mc", "fabric", "1.20.2"), ] { let id = add_version(api, project, number, loader, mc).await; if mc == "1.20.1" { @@ -150,7 +150,7 @@ async fn bulk_project_versions_filter_each_project_without_collapsing_releases() api, &[ ("project_ids", selectors), - ("game_versions", json!(["1.19.4"])), + ("game_versions", json!(["1.20.2"])), ], None, ) @@ -441,7 +441,8 @@ async fn bulk_project_versions_apply_visibility_before_global_pagination() { alpha.version_id.clone(), beta.version_id.clone(), hidden - ]) + ]), + "the owner must see listed versions from both projects and their draft version" ); }) .await;