From ff908ef8da2a23320582975a4504064030f3346f Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:49:43 +0100 Subject: [PATCH 1/5] fix up modview backend --- apps/labrinth/src/models/v3/thread_issues.rs | 1 + apps/labrinth/src/routes/v3/threads.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 8ba63d4f333..11bba8d176d 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -98,6 +98,7 @@ pub enum ThreadIssueTarget { links: HashMap, }, AddGalleryImages { + #[serde(default)] original_image_ids: Vec, auto_resolve: bool, }, diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index e68088104bf..c82584b9cf5 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -1076,7 +1076,7 @@ pub async fn thread_send_message_internal( SET thread_message_id = $1 WHERE id = $2 ", - thread.id.0, + id.0, image_id.0 as i64 ) .execute(&mut transaction) From 937ef98286edde2526a3f22691f01fdaebc2b2e2 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:53:28 +0100 Subject: [PATCH 2/5] when a mod approves a project, mark all facets as mod verified AND addressed --- apps/labrinth/src/routes/v3/projects/mutation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 16c6b861bf8..cab3c83fbc5 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -350,7 +350,7 @@ pub(crate) async fn finalize_project_edit( { DBThreadIssue::update_facet_flags( facet.id.into(), - None, + Some(true), Some(true), &mut transaction, ) From b9fa5b60c75c067d5c0da1698f2829629107745c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:58:35 +0100 Subject: [PATCH 3/5] also check auth approve on exit tech rev --- ...7a3fa040e842dfbcce1e3420d9986bb12017d.json | 22 + ...5badea62230cf9447d490463196adf5d04da8.json | 22 + ...56c778905419b5edc2fb6c93acb71dcabc075.json | 15 + ...56c6910d380eb1996c8692bc771a1a67b5937.json | 20 + .../src/routes/internal/delphi/mod.rs | 20 +- .../internal/delphi/tech_review_queue.rs | 38 +- .../routes/internal/moderation/tech_review.rs | 46 +- .../moderation/tech_review/rules_scan.rs | 31 +- apps/labrinth/src/routes/v3/projects/mod.rs | 62 +- .../src/routes/v3/projects/mutation.rs | 162 ++++- apps/labrinth/src/routes/v3/threads.rs | 8 +- apps/labrinth/tests/project.rs | 643 ++++++++++++++++++ 12 files changed, 1003 insertions(+), 86 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-052a70e917cdfea5d29785ec8607a3fa040e842dfbcce1e3420d9986bb12017d.json create mode 100644 apps/labrinth/.sqlx/query-56a9276c3bb5d826fc5d791fccb5badea62230cf9447d490463196adf5d04da8.json create mode 100644 apps/labrinth/.sqlx/query-9e1d4fca7ba37cfbc1c3437d78b56c778905419b5edc2fb6c93acb71dcabc075.json create mode 100644 apps/labrinth/.sqlx/query-a14621ef6ca02af546ae5d2319956c6910d380eb1996c8692bc771a1a67b5937.json diff --git a/apps/labrinth/.sqlx/query-052a70e917cdfea5d29785ec8607a3fa040e842dfbcce1e3420d9986bb12017d.json b/apps/labrinth/.sqlx/query-052a70e917cdfea5d29785ec8607a3fa040e842dfbcce1e3420d9986bb12017d.json new file mode 100644 index 00000000000..6ac03479064 --- /dev/null +++ b/apps/labrinth/.sqlx/query-052a70e917cdfea5d29785ec8607a3fa040e842dfbcce1e3420d9986bb12017d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT user_id\n\t\t\tFROM team_members\n\t\t\tWHERE team_id = $1 AND accepted\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "052a70e917cdfea5d29785ec8607a3fa040e842dfbcce1e3420d9986bb12017d" +} diff --git a/apps/labrinth/.sqlx/query-56a9276c3bb5d826fc5d791fccb5badea62230cf9447d490463196adf5d04da8.json b/apps/labrinth/.sqlx/query-56a9276c3bb5d826fc5d791fccb5badea62230cf9447d490463196adf5d04da8.json new file mode 100644 index 00000000000..db2746ad6a5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-56a9276c3bb5d826fc5d791fccb5badea62230cf9447d490463196adf5d04da8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT id\n\t\tFROM mods\n\t\tWHERE id = ANY($1::bigint[])\n\t\tORDER BY id\n\t\tFOR NO KEY UPDATE\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "56a9276c3bb5d826fc5d791fccb5badea62230cf9447d490463196adf5d04da8" +} diff --git a/apps/labrinth/.sqlx/query-9e1d4fca7ba37cfbc1c3437d78b56c778905419b5edc2fb6c93acb71dcabc075.json b/apps/labrinth/.sqlx/query-9e1d4fca7ba37cfbc1c3437d78b56c778905419b5edc2fb6c93acb71dcabc075.json new file mode 100644 index 00000000000..2ccbb6d9f19 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9e1d4fca7ba37cfbc1c3437d78b56c778905419b5edc2fb6c93acb71dcabc075.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tUPDATE mods\n\t\t\tSET status = $1, approved = COALESCE(approved, NOW())\n\t\t\tWHERE id = $2\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9e1d4fca7ba37cfbc1c3437d78b56c778905419b5edc2fb6c93acb71dcabc075" +} diff --git a/apps/labrinth/.sqlx/query-a14621ef6ca02af546ae5d2319956c6910d380eb1996c8692bc771a1a67b5937.json b/apps/labrinth/.sqlx/query-a14621ef6ca02af546ae5d2319956c6910d380eb1996c8692bc771a1a67b5937.json new file mode 100644 index 00000000000..ee6ca543be8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a14621ef6ca02af546ae5d2319956c6910d380eb1996c8692bc771a1a67b5937.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT DISTINCT project_id AS \"project_id!: DBProjectId\"\n\t\tFROM delphi_issue_details_with_statuses\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: DBProjectId", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true + ] + }, + "hash": "a14621ef6ca02af546ae5d2319956c6910d380eb1996c8692bc771a1a67b5937" +} diff --git a/apps/labrinth/src/routes/internal/delphi/mod.rs b/apps/labrinth/src/routes/internal/delphi/mod.rs index 38204d38f73..082873d2c42 100644 --- a/apps/labrinth/src/routes/internal/delphi/mod.rs +++ b/apps/labrinth/src/routes/internal/delphi/mod.rs @@ -221,6 +221,12 @@ async fn ingest_report_deserialized( return Ok(()); } + tech_review_queue::lock_projects( + &[DBProjectId::from(report.project_id)], + &mut transaction, + ) + .await?; + AdvisoryLock::DelphiFile(file_id) .acquire(&mut transaction) .await @@ -305,7 +311,7 @@ async fn ingest_report_deserialized( .await .wrap_internal_err("failed to apply delphi rules to new issue details")?; - tech_review_queue::sync_projects( + let exited_projects = tech_review_queue::sync_projects( &[DBProjectId::from(report.project_id)], tech_review_queue::TechReviewRemovalReason::ScanCompleted, &mut transaction, @@ -313,10 +319,14 @@ async fn ingest_report_deserialized( .await .wrap_api_err("synchronizing project technical-review state")?; - transaction - .commit() - .await - .wrap_internal_err("failed to commit Delphi ingest transaction")?; + let prepared = + crate::routes::v3::projects::mutation::prepare_technical_review_exits( + &exited_projects, + &mut transaction, + &redis, + ) + .await?; + prepared.commit(transaction, &redis).await?; if !clean { report.send_to_slack(&pool, &redis).await.ok(); diff --git a/apps/labrinth/src/routes/internal/delphi/tech_review_queue.rs b/apps/labrinth/src/routes/internal/delphi/tech_review_queue.rs index 985db1d3712..eb54b807a7f 100644 --- a/apps/labrinth/src/routes/internal/delphi/tech_review_queue.rs +++ b/apps/labrinth/src/routes/internal/delphi/tech_review_queue.rs @@ -23,6 +23,26 @@ pub enum TechReviewRemovalReason { FileDeleted, } +pub(crate) async fn lock_projects( + project_ids: &[DBProjectId], + txn: &mut PgTransaction<'_>, +) -> Result<(), ApiError> { + sqlx::query!( + r#" + SELECT id + FROM mods + WHERE id = ANY($1::bigint[]) + ORDER BY id + FOR NO KEY UPDATE + "#, + &project_ids.iter().map(|id| id.0).collect::>(), + ) + .fetch_all(&mut *txn) + .await + .wrap_internal_err("locking projects for technical review transitions")?; + Ok(()) +} + pub async fn add_projects( project_ids: &[DBProjectId], txn: &mut PgTransaction<'_>, @@ -32,6 +52,8 @@ pub async fn add_projects( return Ok(()); } + lock_projects(&project_ids, txn).await?; + let rows = sqlx::query!( r#" WITH inserted AS ( @@ -76,12 +98,14 @@ pub async fn remove_projects( project_ids: &[DBProjectId], reason: TechReviewRemovalReason, txn: &mut PgTransaction<'_>, -) -> Result<(), ApiError> { +) -> Result, ApiError> { let project_ids = project_ids.iter().copied().unique().collect::>(); if project_ids.is_empty() { - return Ok(()); + return Ok(Vec::new()); } + lock_projects(&project_ids, txn).await?; + let rows = sqlx::query!( r#" WITH removed AS ( @@ -108,6 +132,8 @@ pub async fn remove_projects( "failed to remove projects from technical review queue", )?; + let removed_project_ids = rows.iter().map(|row| row.project_id).collect(); + let body = match reason { TechReviewRemovalReason::RulesChanged | TechReviewRemovalReason::ScanCompleted => { @@ -130,19 +156,21 @@ pub async fn remove_projects( .wrap_internal_err("failed to add exiting technical review message")?; } - Ok(()) + Ok(removed_project_ids) } pub async fn sync_projects( project_ids: &[DBProjectId], removal_reason: TechReviewRemovalReason, txn: &mut PgTransaction<'_>, -) -> Result<(), ApiError> { +) -> Result, ApiError> { let project_ids = project_ids.iter().copied().unique().collect::>(); if project_ids.is_empty() { - return Ok(()); + return Ok(Vec::new()); } + lock_projects(&project_ids, txn).await?; + let rows = sqlx::query!( r#" SELECT diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index da6aa4ff8d8..4fb0efd6898 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -1262,7 +1262,7 @@ pub async fn submit_report( .wrap_internal_err("failed to add tech review message")?; } - let prepared = if verdict == DelphiVerdict::Unsafe { + let mut prepared = if verdict == DelphiVerdict::Unsafe { Some( crate::routes::v3::projects::mutation::prepare_mutations( &[project_id], @@ -1276,7 +1276,7 @@ pub async fn submit_report( None }; - sqlx::query!( + let removed = sqlx::query!( r#" DELETE FROM delphi_tech_review_queue WHERE project_id = $1 @@ -1289,12 +1289,20 @@ pub async fn submit_report( "failed to remove project from technical review queue", )?; + if prepared.is_none() && removed.rows_affected() > 0 { + prepared = Some(crate::routes::v3::projects::mutation::prepare_technical_review_exits( + &[project_id], &mut txn, &redis, + ).await?); + } + if let Some(prepared) = prepared { prepared.commit(txn, &redis).await?; - search_state - .queue - .push_project_change(project_id.into()) - .await; + if verdict == DelphiVerdict::Unsafe { + search_state + .queue + .push_project_change(project_id.into()) + .await; + } } else { txn.commit() .await @@ -1463,7 +1471,7 @@ pub async fn update_issue_details( .map(|row| row.project_id) .collect::>(); - tech_review_queue::sync_projects( + let exited_projects = tech_review_queue::sync_projects( &affected_project_ids, tech_review_queue::TechReviewRemovalReason::RulesChanged, &mut txn, @@ -1471,9 +1479,14 @@ pub async fn update_issue_details( .await .wrap_api_err("executing `tech_review_queue::sync_projects`")?; - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; + let prepared = + crate::routes::v3::projects::mutation::prepare_technical_review_exits( + &exited_projects, + &mut txn, + &redis, + ) + .await?; + prepared.commit(txn, &redis).await?; Ok(()) } @@ -1595,7 +1608,7 @@ pub async fn update_global_issue_details( "failed to fetch projects affected by global detail updates", )?; - tech_review_queue::sync_projects( + let exited_projects = tech_review_queue::sync_projects( &affected_projects .into_iter() .map(|row| row.project_id) @@ -1606,9 +1619,14 @@ pub async fn update_global_issue_details( .await .wrap_api_err("executing `tech_review_queue::sync_projects`")?; - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; + let prepared = + crate::routes::v3::projects::mutation::prepare_technical_review_exits( + &exited_projects, + &mut txn, + &redis, + ) + .await?; + prepared.commit(txn, &redis).await?; Ok(()) } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index c54c59a7049..5bc21af7e32 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -401,7 +401,7 @@ pub async fn scan_rules( let _ = sender.send(Bytes::from_static(b": connected\n\n")); actix_web::rt::spawn(async move { - match run_scan(transaction, &sender).await { + match run_scan(transaction, &sender, &redis).await { Ok(summary) => { let event = RuleScanEvent { phase: "complete", @@ -461,7 +461,22 @@ pub async fn scan_rules( async fn run_scan( mut transaction: PgTransaction<'static>, sender: &mpsc::UnboundedSender, + redis: &RedisPool, ) -> Result { + let project_ids = sqlx::query!( + r#" + SELECT DISTINCT project_id AS "project_id!: DBProjectId" + FROM delphi_issue_details_with_statuses + "#, + ) + .fetch_all(&mut transaction) + .await + .wrap_err("fetching projects affected by delphi rule scan")? + .into_iter() + .map(|row| row.project_id) + .collect::>(); + tech_review_queue::lock_projects(&project_ids, &mut transaction).await?; + sqlx::query!("LOCK TABLE delphi_rules IN SHARE MODE") .execute(&mut transaction) .await @@ -755,7 +770,7 @@ async fn run_scan( &mut transaction, ) .await?; - tech_review_queue::remove_projects( + let exited_projects = tech_review_queue::remove_projects( &affected_projects .iter() .filter(|project| !project.new_needs_review) @@ -778,10 +793,14 @@ async fn run_scan( .await .wrap_err("failed to delete retired delphi rules")?; - transaction - .commit() - .await - .wrap_err("failed to commit the delphi rule scan")?; + let prepared = + crate::routes::v3::projects::mutation::prepare_technical_review_exits( + &exited_projects, + &mut transaction, + redis, + ) + .await?; + prepared.commit(transaction, redis).await?; Ok(ScanSummary { revision, diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index e61c949815a..12ad9d59987 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1572,16 +1572,9 @@ async fn submit_project_for_review( .wrap_internal_err("failed to remove archival disclosure")?; } - let state = + let mut state = mutation::sync_project_state(project.inner.id, transaction, redis) .await?; - let auto_approval_status = if user.role.is_mod() { - None - } else { - state.auto_approval_status() - }; - let auto_approved = auto_approval_status.is_some(); - let new_status = auto_approval_status.unwrap_or(ProjectStatus::Processing); sqlx::query!( " @@ -1599,7 +1592,19 @@ async fn submit_project_for_review( .await .wrap_internal_err("querying database for `project_edit_internal`")?; - if notify_team_members || auto_approved { + if state.status().is_approved() + || !user.role.is_mod() + && matches!( + state.status(), + ProjectStatus::Rejected | ProjectStatus::Withheld + ) + && state.try_auto_approve(transaction, redis).await? + { + return Ok(true); + } + + let new_status = ProjectStatus::Processing; + if notify_team_members { let notified_members = sqlx::query!( " SELECT tm.user_id id @@ -1628,16 +1633,10 @@ async fn submit_project_for_review( )?; NotificationBuilder { - body: if auto_approved { - NotificationBody::ProjectStatusApproved { - project_id: project.inner.id.into(), - } - } else { - NotificationBody::ProjectStatusNeutral { - project_id: project.inner.id.into(), - old_status: project.inner.status, - new_status, - } + body: NotificationBody::ProjectStatusNeutral { + project_id: project.inner.id.into(), + old_status: project.inner.status, + new_status, }, } .insert_many(notified_members, &mut *transaction, redis) @@ -1648,21 +1647,10 @@ async fn submit_project_for_review( } ThreadMessageBuilder { - author_id: if auto_approved { - None - } else { - Some(user.id.into()) - }, - body: if auto_approved { - MessageBody::AutoApproval { - old_status: project.inner.status, - new_status, - } - } else { - MessageBody::StatusChange { - new_status, - old_status: project.inner.status, - } + author_id: Some(user.id.into()), + body: MessageBody::StatusChange { + new_status, + old_status: project.inner.status, }, thread_id: project.thread_id, hide_identity: false, @@ -1676,19 +1664,17 @@ async fn submit_project_for_review( sqlx::query!( " UPDATE mods - SET status = $1, - approved = CASE WHEN $3 THEN COALESCE(approved, NOW()) ELSE approved END + SET status = $1 WHERE (id = $2) ", new_status.as_str(), project.inner.id as db_ids::DBProjectId, - auto_approved, ) .execute(&mut *transaction) .await .wrap_internal_err("querying database for `project_edit_internal`")?; - Ok(auto_approved) + Ok(false) } pub async fn edit_project_categories( diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index cab3c83fbc5..e7b2375bd53 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -1,18 +1,22 @@ use eyre::eyre; use xredis::RedisPool; +use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::ProjectQueryResult; +use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{ DBProjectId, DBTeamId, DBThreadIssue, DBThreadIssueFacetId, DBUserId, DBVersionId, }; use crate::database::{PgTransaction, models as db_models}; use crate::models::ids::ProjectId; +use crate::models::notifications::NotificationBody; use crate::models::projects::{Project, ProjectStatus, Version}; use crate::models::thread_issues::{ ThreadIssueContext, ThreadIssueTarget, ThreadIssueTeamMember, ThreadIssueValueState, ThreadIssueVerdict, }; +use crate::models::threads::MessageBody; use crate::models::users::User; use crate::routes::ApiError; use crate::routes::internal::delphi; @@ -33,11 +37,13 @@ pub(crate) struct SyncedProjectState { } impl SyncedProjectState { - pub(crate) fn auto_approval_status(&self) -> Option { - if !matches!( - self.project.status, - ProjectStatus::Rejected | ProjectStatus::Withheld - ) || self.has_outstanding_technical_review + pub(crate) fn status(&self) -> ProjectStatus { + self.project.status + } + + fn auto_approval_status(&self) -> Option { + if self.has_required_nags + || self.has_outstanding_technical_review || self.thread_issues.is_empty() || self .thread_issues @@ -57,6 +63,97 @@ impl SyncedProjectState { ) } + async fn verify_facets( + &self, + transaction: &mut PgTransaction<'_>, + ) -> Result<(), ApiError> { + for facet in self.thread_issues.iter().flat_map(|issue| &issue.facets) { + DBThreadIssue::update_facet_flags( + facet.id.into(), + Some(true), + Some(true), + transaction, + ) + .await + .wrap_internal_err("verifying project moderation issues")?; + } + Ok(()) + } + + pub(crate) async fn try_auto_approve( + &mut self, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, + ) -> Result { + let Some(new_status) = self.auto_approval_status() else { + return Ok(false); + }; + let old_status = self.project.status; + + sqlx::query!( + r#" + UPDATE mods + SET status = $1, approved = COALESCE(approved, NOW()) + WHERE id = $2 + "#, + new_status.as_str(), + self.data.inner.id as DBProjectId, + ) + .execute(&mut *transaction) + .await + .wrap_internal_err("auto-approving project")?; + + self.verify_facets(transaction).await?; + + ThreadMessageBuilder { + author_id: None, + body: MessageBody::AutoApproval { + old_status, + new_status, + }, + thread_id: self.data.thread_id, + hide_identity: false, + } + .insert(transaction) + .await + .wrap_internal_err("recording project auto-approval")?; + + let notified_members = sqlx::query!( + r#" + SELECT user_id + FROM team_members + WHERE team_id = $1 AND accepted + "#, + self.data.inner.team_id as DBTeamId, + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching project approval notification recipients")? + .into_iter() + .map(|row| DBUserId(row.user_id)) + .collect::>(); + + for body in [ + NotificationBody::StatusChange { + project_id: self.data.inner.id.into(), + old_status, + new_status, + }, + NotificationBody::ProjectStatusApproved { + project_id: self.data.inner.id.into(), + }, + ] { + NotificationBuilder { body } + .insert_many(notified_members.clone(), transaction, redis) + .await + .wrap_internal_err("notifying project team of auto-approval")?; + } + + self.data.inner.status = new_status; + self.project.status = new_status; + Ok(true) + } + pub(crate) fn can_address_facet( &self, facet_id: DBThreadIssueFacetId, @@ -87,7 +184,7 @@ pub(crate) async fn sync_project_state( transaction: &mut PgTransaction<'_>, redis: &RedisPool, ) -> Result { - delphi::tech_review_queue::sync_projects( + let exited_projects = delphi::tech_review_queue::sync_projects( &[project_id], delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, transaction, @@ -95,6 +192,25 @@ pub(crate) async fn sync_project_state( .await .wrap_api_err("synchronizing project technical review state")?; + let mut state = load_project_state(project_id, transaction, redis).await?; + if exited_projects.contains(&project_id) + && state.status() == ProjectStatus::Processing + { + state.try_auto_approve(transaction, redis).await?; + } + Ok(state) +} + +/// Loads project state without reconciling technical review membership. +/// Explicit review exits must not re-add a project based on its existing scan details. +pub(crate) async fn load_project_state( + project_id: DBProjectId, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, +) -> Result { + delphi::tech_review_queue::lock_projects(&[project_id], transaction) + .await?; + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) .fetch_one(&mut *transaction) .await @@ -238,6 +354,28 @@ pub(crate) struct PreparedMutations { states: Vec, } +pub(crate) async fn prepare_technical_review_exits( + project_ids: &[DBProjectId], + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, +) -> Result { + let mut project_ids = project_ids.to_vec(); + project_ids.sort_by_key(|project_id| project_id.0); + project_ids.dedup(); + + let mut states = Vec::with_capacity(project_ids.len()); + for project_id in project_ids { + let mut state = + load_project_state(project_id, transaction, redis).await?; + if state.status() == ProjectStatus::Processing { + state.try_auto_approve(transaction, redis).await?; + } + states.push(state); + } + + Ok(PreparedMutations { states }) +} + pub(crate) async fn prepare_mutations( project_ids: &[DBProjectId], transaction: &mut PgTransaction<'_>, @@ -346,17 +484,7 @@ pub(crate) async fn finalize_project_edit( ))); } - for facet in state.thread_issues.iter().flat_map(|issue| &issue.facets) - { - DBThreadIssue::update_facet_flags( - facet.id.into(), - Some(true), - Some(true), - &mut transaction, - ) - .await - .wrap_internal_err("verifying project moderation issues")?; - } + state.verify_facets(&mut transaction).await?; } transaction diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index c82584b9cf5..8c8acc57e57 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -557,6 +557,12 @@ async fn prepare_thread_issue_mutation( project_id: database::models::DBProjectId, transaction: &mut database::PgTransaction<'_>, ) -> Result<(), ApiError> { + crate::routes::internal::delphi::tech_review_queue::lock_projects( + &[project_id], + transaction, + ) + .await?; + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) .fetch_one(&mut *transaction) .await @@ -731,7 +737,7 @@ pub async fn thread_issue_facet_edit( ))); } if edit.user_addressed == Some(true) { - let state = super::projects::mutation::sync_project_state( + let state = super::projects::mutation::load_project_state( project_id, &mut transaction, &redis, diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 15856f26b67..1885757620b 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -2451,3 +2451,646 @@ async fn test_draft_description_save_allows_required_nags() { assert!(validation["nags"].as_array().unwrap().iter().any(|nag| nag["kind"] == "missing_alt_text" && nag["severity"] == "warning")); }).await; } + +async fn queue_project_for_technical_review( + test_env: &TestEnvironment, + patch: serde_json::Value, + issue_facets: Vec>, +) { + let project = &test_env.dummy.project_alpha; + let response = test_env + .api + .edit_project(&project.project_id, patch, ADMIN_USER_PAT) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + + if !issue_facets.is_empty() { + let issues = issue_facets + .into_iter() + .map(|facets| { + let facets = facets + .into_iter() + .map(|what| json!({ "what": what })) + .collect::>(); + json!({ + "why": { "message": "Technical review exit regression" }, + "facets": facets, + }) + }) + .collect::>(); + let response = test_env + .api + .call( + test::TestRequest::put() + .uri(&format!("/v3/thread/{}/issue", project.thread_id)) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "issues": issues })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + } + + let project_id = DBProjectId::from(project.project_id_parsed); + let mut txn = test_env.db.pool.begin().await.unwrap(); + labrinth::routes::internal::delphi::tech_review_queue::add_projects( + &[project_id], + &mut txn, + ) + .await + .unwrap(); + txn.commit().await.unwrap(); +} + +async fn assert_no_technical_review_auto_approval( + test_env: &TestEnvironment, + expected_status: ProjectStatus, + expected_verdicts: &[&str], +) { + let project = &test_env.dummy.project_alpha; + let after = test_env + .api + .get_project_deserialized(&project.project_id, MOD_USER_PAT) + .await; + assert_eq!(after.status, expected_status); + + let queued = sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 FROM delphi_tech_review_queue WHERE project_id = $1 + ) AS "exists!" + "#, + DBProjectId::from(project.project_id_parsed).0, + ) + .fetch_one(&**test_env.db.ro_pool) + .await + .unwrap(); + assert!(!queued); + + let response = test_env + .api + .get_thread(&project.thread_id, MOD_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let thread: serde_json::Value = test::read_body_json(response).await; + assert!( + !thread["messages"] + .as_array() + .unwrap() + .iter() + .any(|message| { message["body"]["type"] == "auto_approval" }) + ); + let issues = thread["issues"].as_array().unwrap(); + assert_eq!(issues.len(), expected_verdicts.len()); + let mut actual_verdicts = issues + .iter() + .map(|issue| { + for facet in issue["facets"].as_array().unwrap() { + assert_eq!(facet["user_addressed"], false); + assert_eq!(facet["moderator_verified"], false); + } + issue["verdict"].as_str().unwrap() + }) + .collect::>(); + actual_verdicts.sort_unstable(); + let mut expected_verdicts = expected_verdicts.to_vec(); + expected_verdicts.sort_unstable(); + assert_eq!(actual_verdicts, expected_verdicts); +} + +#[actix_rt::test] +async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { + use labrinth::routes::internal::delphi::tech_review_queue; + + for (requested_status, old_status) in [ + ProjectStatus::Approved, + ProjectStatus::Unlisted, + ProjectStatus::Private, + ] + .into_iter() + .flat_map(|status| { + [ProjectStatus::Processing, ProjectStatus::Rejected] + .map(|old_status| (status, old_status)) + }) { + with_test_environment( + None, + move |test_env: TestEnvironment| async move { + let api = &test_env.api; + let project = &test_env.dummy.project_alpha; + queue_project_for_technical_review( + &test_env, + json!({ "status": old_status, "requested_status": requested_status }), + vec![ + vec![ + json!({ "type": "remove_tags", "value": { "tags": ["magic"] } }), + json!({ "type": "remove_tags", "value": { "tags": ["combat"] } }), + ], + vec![ + json!({ "type": "remove_tags", "value": { "tags": ["economy"] } }), + ], + ], + ) + .await; + if old_status == ProjectStatus::Rejected { + let mut txn = test_env.db.pool.begin().await.unwrap(); + tech_review_queue::remove_projects( + &[DBProjectId::from(project.project_id_parsed)], + tech_review_queue::TechReviewRemovalReason::ScanCompleted, + &mut txn, + ).await.unwrap(); + txn.commit().await.unwrap(); + } + + let before = api + .get_project_deserialized(&project.project_id, MOD_USER_PAT) + .await; + assert_eq!(before.status, old_status); + let response = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/project/{}/validate", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let validation: serde_json::Value = + test::read_body_json(response).await; + assert!( + validation["nags"] + .as_array() + .unwrap() + .iter() + .all(|nag| nag["severity"] != "required") + ); + + let response = + api.get_thread(&project.thread_id, MOD_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + let thread: serde_json::Value = + test::read_body_json(response).await; + assert_eq!(thread["issues"].as_array().unwrap().len(), 2); + for issue in thread["issues"].as_array().unwrap() { + assert_eq!(issue["verdict"], "resolved"); + for facet in issue["facets"].as_array().unwrap() { + assert_eq!(facet["user_addressed"], false); + assert_eq!(facet["moderator_verified"], false); + } + } + let response = api + .get_user_notifications(USER_USER_ID, USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let notifications_before: Vec = + test::read_body_json(response).await; + + let request = if old_status == ProjectStatus::Processing { + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + } else { + test::TestRequest::patch() + .uri(&format!("/v3/project/{}", project.project_id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ "status": "processing" })) + }; + let response = api.call(request.to_request()).await; + assert_status!(&response, StatusCode::NO_CONTENT); + + let after = api + .get_project_deserialized( + &project.project_id, + USER_USER_PAT, + ) + .await; + assert_eq!(after.status, requested_status); + assert_eq!(after.requested_status, Some(requested_status)); + let queued = sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 FROM delphi_tech_review_queue WHERE project_id = $1 + ) AS "exists!" + "#, + DBProjectId::from(project.project_id_parsed).0, + ) + .fetch_one(&**test_env.db.ro_pool) + .await + .unwrap(); + assert!(!queued); + + let response = + api.get_thread(&project.thread_id, MOD_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + let thread: serde_json::Value = + test::read_body_json(response).await; + let auto_approvals = thread["messages"] + .as_array() + .unwrap() + .iter() + .filter(|message| { + message["body"]["type"] == "auto_approval" + }) + .collect::>(); + assert_eq!(auto_approvals.len(), 1); + assert_eq!( + auto_approvals[0]["body"]["old_status"], + json!(old_status) + ); + assert_eq!( + auto_approvals[0]["body"]["new_status"], + json!(requested_status) + ); + assert!(auto_approvals[0]["author_id"].is_null()); + let facets = thread["issues"] + .as_array() + .unwrap() + .iter() + .flat_map(|issue| issue["facets"].as_array().unwrap()) + .collect::>(); + assert_eq!(facets.len(), 3); + for facet in facets { + assert_eq!(facet["verdict"], "resolved"); + assert_eq!(facet["user_addressed"], true); + assert_eq!(facet["moderator_verified"], true); + } + + let response = api + .get_user_notifications(USER_USER_ID, USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let notifications_after: Vec = + test::read_body_json(response).await; + let new_notifications = notifications_after + .iter() + .filter(|notification| { + !notifications_before + .iter() + .any(|before| before["id"] == notification["id"]) + }) + .collect::>(); + assert_eq!(new_notifications.len(), 2); + assert!(new_notifications.iter().any( + |notification| notification["body"] + == json!({ + "type": "status_change", "project_id": project.project_id, + "old_status": old_status, "new_status": requested_status, + }) + )); + assert!(new_notifications.iter().any( + |notification| notification["body"] + == json!({ + "type": "project_status_approved", "project_id": project.project_id, + }) + )); + + if old_status != ProjectStatus::Processing { + return; + } + let response = api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = + api.get_thread(&project.thread_id, MOD_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + let thread: serde_json::Value = + test::read_body_json(response).await; + assert_eq!( + thread["messages"] + .as_array() + .unwrap() + .iter() + .filter(|message| message["body"]["type"] + == "auto_approval") + .count(), + 1 + ); + let response = api + .get_user_notifications(USER_USER_ID, USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let notifications_repeated: Vec = + test::read_body_json(response).await; + assert_eq!(notifications_repeated, notifications_after); + }, + ) + .await; + } +} + +#[actix_rt::test] +async fn test_safe_technical_review_submit_requires_valid_project_and_exit() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let project = &test_env.dummy.project_alpha; + let description = api + .get_project_deserialized(&project.project_id, MOD_USER_PAT) + .await + .description; + queue_project_for_technical_review( + &test_env, + json!({ "status": "processing", "description": "" }), + vec![vec![ + json!({ "type": "remove_tags", "value": { "tags": ["magic"] } }), + ]], + ) + .await; + let response = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/project/{}/validate", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let validation: serde_json::Value = + test::read_body_json(response).await; + assert!(validation["nags"].as_array().unwrap().iter().any(|nag| { + nag["kind"] == "add_description" + && nag["severity"] == "required" + })); + + let response = api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Processing, + &["resolved"], + ) + .await; + + let response = api + .edit_project( + &project.project_id, + json!({ "description": description }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let response = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/project/{}/validate", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let validation: serde_json::Value = + test::read_body_json(response).await; + assert!( + validation["nags"] + .as_array() + .unwrap() + .iter() + .all(|nag| nag["severity"] != "required") + ); + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Processing, + &["resolved"], + ) + .await; + let response = api + .get_user_notifications(USER_USER_ID, USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let notifications_before: serde_json::Value = + test::read_body_json(response).await; + + let response = api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + project.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Processing, + &["resolved"], + ) + .await; + let response = api + .get_user_notifications(USER_USER_ID, USER_USER_PAT) + .await; + assert_status!(&response, StatusCode::OK); + let notifications_after: serde_json::Value = + test::read_body_json(response).await; + assert_eq!(notifications_after, notifications_before); + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_safe_technical_review_exit_requires_every_facet_resolved() { + for same_issue in [true, false] { + with_test_environment( + None, + move |test_env: TestEnvironment| async move { + let project_id = &test_env.dummy.project_alpha.project_id; + let resolved = json!({ + "type": "remove_tags", "value": { "tags": ["magic"] }, + }); + let open = json!({ "type": "mark_addressed" }); + let issues = if same_issue { + vec![vec![resolved, open]] + } else { + vec![vec![resolved], vec![open]] + }; + queue_project_for_technical_review( + &test_env, + json!({ "status": "processing" }), + issues, + ) + .await; + let response = test_env + .api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{project_id}", + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + let verdicts: &[&str] = if same_issue { + &["open"] + } else { + &["resolved", "open"] + }; + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Processing, + verdicts, + ) + .await; + }, + ) + .await; + } +} + +#[actix_rt::test] +async fn test_safe_technical_review_exit_requires_at_least_one_issue() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + queue_project_for_technical_review( + &test_env, + json!({ "status": "processing" }), + vec![], + ) + .await; + let response = test_env + .api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + test_env.dummy.project_alpha.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Processing, + &[], + ) + .await; + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_safe_technical_review_exit_requires_processing_status() { + for status in [ + ProjectStatus::Draft, + ProjectStatus::Rejected, + ProjectStatus::Withheld, + ] { + with_test_environment( + None, + move |test_env: TestEnvironment| async move { + queue_project_for_technical_review( + &test_env, + json!({ "status": status }), + vec![vec![ + json!({ "type": "remove_tags", "value": { "tags": ["magic"] } }), + ]], + ) + .await; + let response = test_env + .api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + test_env.dummy.project_alpha.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "safe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + assert_no_technical_review_auto_approval( + &test_env, + status, + &["resolved"], + ) + .await; + }, + ) + .await; + } +} + +#[actix_rt::test] +async fn test_unsafe_technical_review_exit_rejects_resolved_project() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + queue_project_for_technical_review( + &test_env, + json!({ "status": "processing", "requested_status": "unlisted" }), + vec![vec![ + json!({ "type": "remove_tags", "value": { "tags": ["magic"] } }), + ]], + ) + .await; + let response = test_env + .api + .call( + test::TestRequest::post() + .uri(&format!( + "/_internal/moderation/tech-review/submit/{}", + test_env.dummy.project_alpha.project_id + )) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "verdict": "unsafe" })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); + assert_no_technical_review_auto_approval( + &test_env, + ProjectStatus::Rejected, + &["resolved"], + ) + .await; + }, + ) + .await; +} From 5a19bb44ea9e4a3242b3d1802e9be00f0ea00191 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:15:58 +0100 Subject: [PATCH 4/5] fix test --- apps/labrinth/tests/project.rs | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 1885757620b..5f2567f161e 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -2560,6 +2560,8 @@ async fn assert_no_technical_review_auto_approval( #[actix_rt::test] async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { + use labrinth::database::models::DBUserId; + use labrinth::database::models::notification_item::DBNotification; use labrinth::routes::internal::delphi::tech_review_queue; for (requested_status, old_status) in [ @@ -2646,6 +2648,12 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { assert_status!(&response, StatusCode::OK); let notifications_before: Vec = test::read_body_json(response).await; + let stored_notifications_before = DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); let request = if old_status == ProjectStatus::Processing { test::TestRequest::post() @@ -2735,7 +2743,7 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { .any(|before| before["id"] == notification["id"]) }) .collect::>(); - assert_eq!(new_notifications.len(), 2); + assert_eq!(new_notifications.len(), 1); assert!(new_notifications.iter().any( |notification| notification["body"] == json!({ @@ -2743,12 +2751,28 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { "old_status": old_status, "new_status": requested_status, }) )); - assert!(new_notifications.iter().any( - |notification| notification["body"] + let stored_notifications_after = DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); + let new_stored_notifications = stored_notifications_after + .iter() + .filter(|notification| { + !stored_notifications_before + .iter() + .any(|before| before.id == notification.id) + }) + .collect::>(); + assert_eq!(new_stored_notifications.len(), 2); + assert!(new_stored_notifications.iter().any(|notification| { + serde_json::to_value(¬ification.body).unwrap() == json!({ - "type": "project_status_approved", "project_id": project.project_id, + "type": "project_status_approved", + "project_id": project.project_id, }) - )); + })); if old_status != ProjectStatus::Processing { return; @@ -2788,6 +2812,16 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { let notifications_repeated: Vec = test::read_body_json(response).await; assert_eq!(notifications_repeated, notifications_after); + let stored_notifications_repeated = DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); + assert_eq!( + stored_notifications_repeated.len(), + stored_notifications_after.len(), + ); }, ) .await; From 92701fbe9e5e994ad07fb70e4020596fec677cb1 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:28 +0100 Subject: [PATCH 5/5] fix ci --- apps/labrinth/tests/project.rs | 110 ++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 5f2567f161e..dbf9b7d6b3e 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -425,8 +425,33 @@ async fn project_redirect_accepts_literal_and_encoded_plus() { #[actix_rt::test] async fn project_redirect_cache_preserves_case_sensitive_ids() { + use labrinth::env::ENV; + use labrinth::util::actix::AppendsMultipart; + with_test_environment_all(None, |env| async move { - let project_id = &env.dummy.project_beta.project_id; + let project_id = "CaseTest".to_string(); + let project_id = &project_id; + let creation_data = get_public_project_creation_data( + "case-sensitive-project", + None, + None, + ); + let response = env + .api + .call( + test::TestRequest::post() + .uri(&format!("/v3/project/{project_id}")) + .append_header(( + "Modrinth-Admin", + ENV.LABRINTH_ADMIN_KEY.clone(), + )) + .append_pat(USER_USER_PAT) + .set_multipart(creation_data.segment_data) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let target_id = &env.dummy.project_alpha.project_id; let alias = project_id.to_lowercase(); assert_ne!( @@ -2560,8 +2585,8 @@ async fn assert_no_technical_review_auto_approval( #[actix_rt::test] async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { - use labrinth::database::models::DBUserId; - use labrinth::database::models::notification_item::DBNotification; + use labrinth::database::models::DBUserId; + use labrinth::database::models::notification_item::DBNotification; use labrinth::routes::internal::delphi::tech_review_queue; for (requested_status, old_status) in [ @@ -2648,12 +2673,12 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { assert_status!(&response, StatusCode::OK); let notifications_before: Vec = test::read_body_json(response).await; - let stored_notifications_before = DBNotification::get_all_user( - DBUserId(USER_USER_ID_PARSED), - &**test_env.db.ro_pool, - ) - .await - .unwrap(); + let stored_notifications_before = DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); let request = if old_status == ProjectStatus::Processing { test::TestRequest::post() @@ -2743,7 +2768,7 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { .any(|before| before["id"] == notification["id"]) }) .collect::>(); - assert_eq!(new_notifications.len(), 1); + assert_eq!(new_notifications.len(), 1); assert!(new_notifications.iter().any( |notification| notification["body"] == json!({ @@ -2751,28 +2776,28 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { "old_status": old_status, "new_status": requested_status, }) )); - let stored_notifications_after = DBNotification::get_all_user( - DBUserId(USER_USER_ID_PARSED), - &**test_env.db.ro_pool, - ) - .await - .unwrap(); - let new_stored_notifications = stored_notifications_after - .iter() - .filter(|notification| { - !stored_notifications_before - .iter() - .any(|before| before.id == notification.id) - }) - .collect::>(); - assert_eq!(new_stored_notifications.len(), 2); - assert!(new_stored_notifications.iter().any(|notification| { - serde_json::to_value(¬ification.body).unwrap() - == json!({ - "type": "project_status_approved", - "project_id": project.project_id, - }) - })); + let stored_notifications_after = DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); + let new_stored_notifications = stored_notifications_after + .iter() + .filter(|notification| { + !stored_notifications_before + .iter() + .any(|before| before.id == notification.id) + }) + .collect::>(); + assert_eq!(new_stored_notifications.len(), 2); + assert!(new_stored_notifications.iter().any(|notification| { + serde_json::to_value(¬ification.body).unwrap() + == json!({ + "type": "project_status_approved", + "project_id": project.project_id, + }) + })); if old_status != ProjectStatus::Processing { return; @@ -2812,16 +2837,17 @@ async fn test_safe_technical_review_exit_auto_approves_resolved_issues() { let notifications_repeated: Vec = test::read_body_json(response).await; assert_eq!(notifications_repeated, notifications_after); - let stored_notifications_repeated = DBNotification::get_all_user( - DBUserId(USER_USER_ID_PARSED), - &**test_env.db.ro_pool, - ) - .await - .unwrap(); - assert_eq!( - stored_notifications_repeated.len(), - stored_notifications_after.len(), - ); + let stored_notifications_repeated = + DBNotification::get_all_user( + DBUserId(USER_USER_ID_PARSED), + &**test_env.db.ro_pool, + ) + .await + .unwrap(); + assert_eq!( + stored_notifications_repeated.len(), + stored_notifications_after.len(), + ); }, ) .await;