headless_lms_server/domain/exercise_services/
answer_uploads.rs1use crate::domain::error::{BadRequestReason, bad_request_with_reason};
9use crate::prelude::*;
10use models::exercise_answer_uploads::AnswerUpload;
11use std::collections::HashSet;
12
13pub fn verify_answer_names_uploads(requested: &[Uuid]) -> Result<(), ControllerError> {
18 if requested.is_empty() {
19 return Err(controller_err!(
20 BadRequest,
21 "A file answer must name at least one uploaded file.".to_string()
22 ));
23 }
24 Ok(())
25}
26
27pub fn verify_uploads_are_usable(
34 requested: &[Uuid],
35 found: &[AnswerUpload],
36) -> Result<(), ControllerError> {
37 for id in requested {
38 match found.iter().find(|upload| &upload.file_upload_id == id) {
39 Some(upload) if upload.deleted => {
40 return Err(bad_request_with_reason(
41 BadRequestReason::UploadExpired,
42 format!("Uploaded file {id} is no longer available; upload it again"),
43 ));
44 }
45 Some(_) => {}
46 None => {
47 return Err(bad_request_with_reason(
48 BadRequestReason::UnknownUpload,
49 format!("Uploaded file {id} was not uploaded for this exercise by this user"),
50 ));
51 }
52 }
53 }
54 Ok(())
55}
56
57pub fn verify_uploads_are_distinct(requested: &[Uuid]) -> Result<(), ControllerError> {
63 let mut seen = HashSet::with_capacity(requested.len());
64 for id in requested {
65 if !seen.insert(id) {
66 return Err(bad_request_with_reason(
67 BadRequestReason::DuplicateUpload,
68 format!("Uploaded file {id} was named more than once"),
69 ));
70 }
71 }
72 Ok(())
73}
74
75pub async fn verify_uploads_belong_to_exercise(
80 conn: &mut PgConnection,
81 exercise_id: Uuid,
82 user_id: Uuid,
83 requested: &[Uuid],
84) -> Result<(), ControllerError> {
85 verify_uploads_are_distinct(requested)?;
86 let recorded = models::exercise_answer_uploads::get_for_exercise_and_user(
87 conn,
88 exercise_id,
89 user_id,
90 requested,
91 )
92 .await?;
93 verify_uploads_are_usable(requested, &recorded)
94}
95
96pub async fn lock_and_verify_uploads_are_usable(
103 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
104 exercise_id: Uuid,
105 user_id: Uuid,
106 requested: &[Uuid],
107) -> Result<(), ControllerError> {
108 let locked = models::exercise_answer_uploads::lock_for_exercise_and_user(
109 tx,
110 exercise_id,
111 user_id,
112 requested,
113 )
114 .await?;
115 verify_uploads_are_usable(requested, &locked)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::test_helper::message_key_of;
122
123 #[test]
124 fn a_submission_naming_no_files_is_accepted() {
125 assert!(verify_uploads_are_usable(&[], &[]).is_ok());
126 }
127
128 #[test]
129 fn a_file_answer_must_name_at_least_one_upload() {
130 assert!(verify_answer_names_uploads(&[Uuid::new_v4()]).is_ok());
131 use actix_web::ResponseError;
132 use actix_web::http::StatusCode;
133 let error = verify_answer_names_uploads(&[])
134 .expect_err("a file answer naming nothing must be rejected");
135 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
136 }
137
138 #[test]
139 fn a_submission_naming_its_own_uploads_is_accepted() {
140 let first = Uuid::new_v4();
141 let second = Uuid::new_v4();
142 let found = vec![
143 AnswerUpload {
144 file_upload_id: second,
145 deleted: false,
146 },
147 AnswerUpload {
148 file_upload_id: first,
149 deleted: false,
150 },
151 ];
152 assert!(verify_uploads_are_usable(&[first, second], &found).is_ok());
154 }
155
156 #[test]
159 fn a_submission_naming_a_foreign_upload_is_rejected_as_unknown() {
160 use actix_web::ResponseError;
161 use actix_web::http::StatusCode;
162 let foreign = Uuid::new_v4();
163 let error = verify_uploads_are_usable(&[foreign], &[])
164 .expect_err("an upload not bound to this exercise and user must be rejected");
165 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
166 assert_eq!(message_key_of(&error), "unknown_upload");
167 }
168
169 #[test]
172 fn a_submission_naming_a_reaped_upload_is_rejected_as_expired() {
173 use actix_web::ResponseError;
174 use actix_web::http::StatusCode;
175 let reaped = Uuid::new_v4();
176 let error = verify_uploads_are_usable(
177 &[reaped],
178 &[AnswerUpload {
179 file_upload_id: reaped,
180 deleted: true,
181 }],
182 )
183 .expect_err("a reaped upload must be rejected");
184 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
185 assert_eq!(message_key_of(&error), "upload_expired");
186 }
187
188 #[test]
191 fn one_unusable_upload_rejects_the_whole_submission() {
192 let good = Uuid::new_v4();
193 let reaped = Uuid::new_v4();
194 let found = vec![
195 AnswerUpload {
196 file_upload_id: good,
197 deleted: false,
198 },
199 AnswerUpload {
200 file_upload_id: reaped,
201 deleted: true,
202 },
203 ];
204 let error = verify_uploads_are_usable(&[good, reaped], &found).expect_err("must reject");
205 assert_eq!(message_key_of(&error), "upload_expired");
206 }
207
208 #[test]
209 fn a_submission_naming_distinct_uploads_is_accepted() {
210 assert!(verify_uploads_are_distinct(&[]).is_ok());
211 assert!(verify_uploads_are_distinct(&[Uuid::new_v4(), Uuid::new_v4()]).is_ok());
212 }
213
214 #[test]
217 fn a_submission_naming_the_same_upload_twice_is_rejected() {
218 use actix_web::ResponseError;
219 use actix_web::http::StatusCode;
220 let repeated = Uuid::new_v4();
221 let error = verify_uploads_are_distinct(&[repeated, Uuid::new_v4(), repeated])
222 .expect_err("a repeated upload id must be rejected");
223 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
224 assert_eq!(message_key_of(&error), "duplicate_upload");
225 }
226}