1use std::sync::Arc;
2
3use crate::{
4 domain::exercise_services::answer_uploads,
5 domain::models_requests::{self, JwtKey},
6 prelude::*,
7};
8use chrono::{Duration, Utc};
9use futures_util::future::OptionFuture;
10use models::{
11 exercises::Exercise,
12 library::grading::{
13 GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult,
14 SubmittedAnswer,
15 },
16 user_exercise_states::ExerciseWithUserState,
17};
18
19pub async fn process_submission(
26 conn: &mut PgConnection,
27 user_id: Uuid,
28 exercise: Exercise,
29 submission: &StudentExerciseSlideSubmission,
30 jwt_key: Arc<JwtKey>,
31 file_store: &dyn FileStore,
32 app_conf: &ApplicationConfiguration,
33) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
34 verify_named_uploads(conn, exercise.id, user_id, submission).await?;
35
36 let mut tx = conn.begin().await?;
37 if let Err(error) =
38 lock_and_verify_named_uploads(&mut tx, exercise.id, user_id, submission).await
39 {
40 tx.rollback().await?;
41 return Err(error);
42 }
43 let result = grade_submission(
44 &mut tx, user_id, exercise, submission, jwt_key, file_store, app_conf,
45 )
46 .await;
47 let result = match result {
48 Ok(result) => result,
49 Err(error) => {
50 if let Err(commit_error) = tx.commit().await {
53 error!("Failed to commit after a failed submission: {commit_error}");
54 }
55 return Err(error);
56 }
57 };
58 tx.commit().await?;
59 Ok(result)
60}
61
62fn named_upload_ids(submission: &StudentExerciseSlideSubmission) -> Vec<Uuid> {
68 submission
69 .exercise_task_submissions
70 .iter()
71 .flat_map(|task_submission| task_submission.named_file_ids().iter().copied())
72 .collect()
73}
74
75async fn verify_named_uploads(
81 conn: &mut PgConnection,
82 exercise_id: Uuid,
83 user_id: Uuid,
84 submission: &StudentExerciseSlideSubmission,
85) -> Result<(), ControllerError> {
86 for task_submission in &submission.exercise_task_submissions {
87 if let SubmittedAnswer::File {
88 file_upload_ids, ..
89 } = task_submission.to_submitted_answer()?
90 {
91 answer_uploads::verify_answer_names_uploads(&file_upload_ids)?;
92 }
93 }
94 let file_upload_ids = named_upload_ids(submission);
95 if file_upload_ids.is_empty() {
96 return Ok(());
97 }
98 answer_uploads::verify_uploads_belong_to_exercise(conn, exercise_id, user_id, &file_upload_ids)
99 .await
100}
101
102async fn lock_and_verify_named_uploads(
105 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
106 exercise_id: Uuid,
107 user_id: Uuid,
108 submission: &StudentExerciseSlideSubmission,
109) -> Result<(), ControllerError> {
110 let file_upload_ids = named_upload_ids(submission);
111 if file_upload_ids.is_empty() {
112 return Ok(());
113 }
114 answer_uploads::lock_and_verify_uploads_are_usable(tx, exercise_id, user_id, &file_upload_ids)
115 .await
116}
117
118async fn grade_submission(
121 conn: &mut PgConnection,
122 user_id: Uuid,
123 exercise: Exercise,
124 submission: &StudentExerciseSlideSubmission,
125 jwt_key: Arc<JwtKey>,
126 file_store: &dyn FileStore,
127 app_conf: &ApplicationConfiguration,
128) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
129 enforce_deadline(conn, &exercise).await?;
130
131 let (course_or_exam_id, last_try) = resolve_course_or_exam_id_and_verify_that_user_can_submit(
132 conn,
133 user_id,
134 &exercise,
135 submission.exercise_slide_id,
136 )
137 .await?;
138
139 let user_exercise_state = models::user_exercise_states::get_user_exercise_state_if_exists(
141 conn,
142 user_id,
143 exercise.id,
144 course_or_exam_id,
145 )
146 .await?
147 .ok_or_else(|| {
148 ControllerError::new(
149 ControllerErrorType::Unauthorized,
150 "Missing exercise state.".to_string(),
151 None,
152 )
153 })?;
154
155 let mut exercise_with_user_state = ExerciseWithUserState::new(exercise, user_exercise_state)?;
156 let mut result = models::library::grading::grade_user_submission(
157 conn,
158 &mut exercise_with_user_state,
159 submission,
160 GradingPolicy::Default,
161 models_requests::fetch_service_info,
162 models_requests::make_grading_request_sender(jwt_key, app_conf.base_url.clone()),
163 file_store,
164 app_conf,
165 )
166 .await?;
167
168 if exercise_with_user_state.is_exam_exercise() {
169 result.clear_grading_information();
171 }
172
173 let score_given = if let Some(exercise_status) = &result.exercise_status {
174 exercise_status.score_given.unwrap_or(0.0)
175 } else {
176 0.0
177 };
178
179 let has_received_full_points = score_given
182 >= exercise_with_user_state.exercise().score_maximum as f32
183 || (score_given - exercise_with_user_state.exercise().score_maximum as f32).abs() < 0.0001;
184 if !has_received_full_points && !last_try {
185 result.clear_model_solution_specs();
186 }
187 Ok(result)
188}
189
190pub async fn verify_user_can_answer_exercise_slide(
197 conn: &mut PgConnection,
198 user_id: Uuid,
199 exercise: &Exercise,
200 slide_id: Uuid,
201) -> Result<(), ControllerError> {
202 enforce_deadline(conn, exercise).await?;
203 resolve_course_or_exam_id_and_verify_that_user_can_submit(conn, user_id, exercise, slide_id)
204 .await?;
205 Ok(())
206}
207
208pub async fn verify_user_can_answer_exercise(
216 conn: &mut PgConnection,
217 user_id: Uuid,
218 exercise: &Exercise,
219) -> Result<(), ControllerError> {
220 enforce_deadline(conn, exercise).await?;
221 let course_or_exam_id =
222 resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
223 verify_any_slide_has_tries_left(conn, user_id, exercise, course_or_exam_id).await
224}
225
226async fn verify_any_slide_has_tries_left(
229 conn: &mut PgConnection,
230 user_id: Uuid,
231 exercise: &Exercise,
232 course_or_exam_id: CourseOrExamId,
233) -> Result<(), ControllerError> {
234 let Some(max_tries_per_slide) = try_limit(exercise) else {
235 return Ok(());
236 };
237 let submission_counts =
238 models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
239 conn,
240 exercise.id,
241 course_or_exam_id,
242 user_id,
243 )
244 .await?;
245 let slides =
246 models::exercise_slides::get_exercise_slides_by_exercise_id(conn, exercise.id).await?;
247 if slides
248 .iter()
249 .any(|slide| submission_counts.get(&slide.id).unwrap_or(&0) < &max_tries_per_slide)
250 {
251 return Ok(());
252 }
253 tracing::error!(
254 user_id = %user_id,
255 exercise_id = %exercise.id,
256 course_or_exam_id = ?course_or_exam_id,
257 max_tries_per_slide = %max_tries_per_slide,
258 "User has run out of tries on every slide of the exercise"
259 );
260 Err(out_of_tries_error())
261}
262
263fn out_of_tries_error() -> ControllerError {
266 controller_err!(BadRequest, "You've ran out of tries.".to_string())
267}
268
269fn try_limit(exercise: &Exercise) -> Option<i64> {
271 exercise
272 .limit_number_of_tries
273 .then_some(exercise.max_tries_per_slide)
274 .flatten()
275 .map(i64::from)
276}
277
278async fn enforce_deadline(
280 conn: &mut PgConnection,
281 exercise: &Exercise,
282) -> Result<(), ControllerError> {
283 let chapter_option_future: OptionFuture<_> = exercise
284 .chapter_id
285 .map(|id| models::chapters::get_chapter(conn, id))
286 .into();
287 let chapter = chapter_option_future.await.transpose()?;
288
289 if let Some(deadline) = exercise
291 .deadline
292 .or_else(|| chapter.and_then(|c| c.deadline))
293 && Utc::now() + Duration::seconds(1) >= deadline
294 {
295 return Err(ControllerError::new(
296 ControllerErrorType::BadRequest,
297 "Exercise deadline passed.".to_string(),
298 None,
299 ));
300 }
301
302 Ok(())
303}
304
305async fn resolve_course_or_exam_id_for_submitting(
310 conn: &mut PgConnection,
311 user_id: Uuid,
312 exercise: &Exercise,
313) -> Result<CourseOrExamId, ControllerError> {
314 let course_id_or_exam_id: CourseOrExamId = if let Some(course_id) = exercise.course_id {
315 let settings = models::user_course_settings::get_user_course_settings_by_course_id(
318 conn, user_id, course_id,
319 )
320 .await?;
321 if let Some(settings) = settings {
322 let token = authorize(conn, Act::View, Some(user_id), Res::Course(course_id)).await?;
323 token.authorized_ok(CourseOrExamId::Course(settings.current_course_id))
324 } else {
325 Err(ControllerError::new(
326 ControllerErrorType::Unauthorized,
327 "User is not enrolled on this course.".to_string(),
328 None,
329 ))
330 }
331 } else if let Some(exam_id) = exercise.exam_id {
332 if models::exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await? {
334 let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
335 token.authorized_ok(CourseOrExamId::Exam(exam_id))
336 } else {
337 Err(ControllerError::new(
338 ControllerErrorType::Unauthorized,
339 "Submissions for this exam are no longer accepted.".to_string(),
340 None,
341 ))
342 }
343 } else {
344 Err(ControllerError::new(
346 ControllerErrorType::InternalServerError,
347 "Exam doesn't belong to either a course nor exam.".to_string(),
348 None,
349 ))
350 }?
351 .data;
352 Ok(course_id_or_exam_id)
353}
354
355async fn resolve_course_or_exam_id_and_verify_that_user_can_submit(
361 conn: &mut PgConnection,
362 user_id: Uuid,
363 exercise: &Exercise,
364 slide_id: Uuid,
365) -> Result<(CourseOrExamId, bool), ControllerError> {
366 let mut last_try = false;
367 let course_id_or_exam_id =
368 resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
369 if let Some(max_tries_per_slide) = try_limit(exercise) {
370 let slide_id_to_submissions_count =
372 models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
373 conn,
374 exercise.id,
375 course_id_or_exam_id,
376 user_id,
377 )
378 .await?;
379
380 let count = slide_id_to_submissions_count.get(&slide_id).unwrap_or(&0);
381 if count >= &max_tries_per_slide {
382 tracing::error!(
383 user_id = %user_id,
384 exercise_id = %exercise.id,
385 slide_id = %slide_id,
386 course_or_exam_id = ?course_id_or_exam_id,
387 current_try_count = %count,
388 max_tries_per_slide = %max_tries_per_slide,
389 limit_number_of_tries = %exercise.limit_number_of_tries,
390 "User has run out of tries for exercise slide submission"
391 );
392 return Err(out_of_tries_error());
393 }
394 if count + 1 >= max_tries_per_slide {
395 last_try = true;
396 }
397 }
398 Ok((course_id_or_exam_id, last_try))
399}
400
401#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::test_helper::*;
407 use models::exercise_answer_uploads::AnswerUploadOrigin;
408 use models::exercise_task_gradings::ExerciseTaskGradingResult;
409 use models::exercise_task_submissions::{AnswerFile, AnswerKind};
410 use models::exercises::GradingProgress;
411 use models::library::grading::StudentExerciseTaskSubmission;
412 use sqlx::Connection;
413 use std::sync::{Arc, Mutex};
414
415 struct Fixture {
417 user: Uuid,
418 course: Uuid,
419 chapter: Uuid,
420 page: Uuid,
421 exercise: Uuid,
422 slide: Uuid,
423 task: Uuid,
424 }
425
426 async fn insert_graded_task(
429 conn: &mut PgConnection,
430 slide: Uuid,
431 internal_url: String,
432 order_number: i32,
433 ) -> Uuid {
434 let slug = format!("submit-test-{}", Uuid::new_v4());
435 let service = models::exercise_services::insert_exercise_service(
436 conn,
437 &models::exercise_services::ExerciseServiceNewOrUpdate {
438 name: slug.clone(),
439 slug: slug.clone(),
440 public_url: "http://example.com/api/service".to_string(),
441 internal_url: Some(internal_url),
442 max_reprocessing_submissions_at_once: 1,
443 },
444 )
445 .await
446 .expect("exercise service");
447 models::exercise_service_info::insert(
448 conn,
449 &models::exercise_service_info::PathInfo {
450 exercise_service_id: service.id,
451 user_interface_iframe_path: "/iframe".to_string(),
452 grade_endpoint_path: "/grade".to_string(),
453 public_spec_endpoint_path: "/public-spec".to_string(),
454 model_solution_spec_endpoint_path: "/model-solution".to_string(),
455 has_custom_view: false,
456 supports_native_client: false,
457 produces_file_answers: false,
458 declares_spec_files: false,
459 },
460 )
461 .await
462 .expect("service info");
463 models::exercise_tasks::insert(
464 conn,
465 models::PKeyPolicy::Generate,
466 models::exercise_tasks::NewExerciseTask {
467 exercise_slide_id: slide,
468 exercise_type: slug,
469 assignment: vec![],
470 public_spec: Some(serde_json::Value::Null),
471 private_spec: Some(serde_json::Value::Null),
472 model_solution_spec: Some(serde_json::Value::Null),
473 order_number,
474 },
475 )
476 .await
477 .expect("exercise task")
478 }
479
480 async fn enroll_and_open(
483 conn: &mut PgConnection,
484 user: Uuid,
485 course: Uuid,
486 instance: Uuid,
487 exercise: Uuid,
488 slide: Uuid,
489 ) {
490 models::course_instance_enrollments::insert_enrollment_and_set_as_current(
491 conn,
492 models::course_instance_enrollments::NewCourseInstanceEnrollment {
493 course_id: course,
494 user_id: user,
495 course_instance_id: instance,
496 },
497 )
498 .await
499 .expect("enrollment");
500 models::user_exercise_states::upsert_selected_exercise_slide_id(
501 conn,
502 user,
503 exercise,
504 Some(course),
505 None,
506 Some(slide),
507 )
508 .await
509 .expect("exercise state");
510 }
511
512 async fn bind_upload(conn: &mut PgConnection, exercise: Uuid, user: Uuid, name: &str) -> Uuid {
515 let file_id = models::file_uploads::insert(
516 conn,
517 name,
518 &format!("exercise-answer-uploads/{}", Uuid::new_v4()),
519 "application/octet-stream",
520 Some(user),
521 Some(3),
522 )
523 .await
524 .expect("file upload");
525 models::exercise_answer_uploads::insert_many(
526 conn,
527 exercise,
528 user,
529 &[file_id],
530 AnswerUploadOrigin::Iframe,
531 )
532 .await
533 .expect("binding");
534 file_id
535 }
536
537 fn file_answer(exercise_task_id: Uuid, data_files: Vec<Uuid>) -> StudentExerciseTaskSubmission {
538 StudentExerciseTaskSubmission::files(
539 exercise_task_id,
540 data_files,
541 Some(serde_json::json!({ "plugin": "said so" })),
542 )
543 }
544
545 async fn submit(
546 conn: &mut PgConnection,
547 fixture: &Fixture,
548 answer: StudentExerciseTaskSubmission,
549 file_store: &dyn FileStore,
550 ) -> Result<models::library::grading::StudentExerciseSlideSubmissionResult, ControllerError>
551 {
552 let exercise = models::exercises::get_by_id(conn, fixture.exercise)
553 .await
554 .expect("exercise");
555 process_submission(
556 conn,
557 fixture.user,
558 exercise,
559 &StudentExerciseSlideSubmission {
560 exercise_slide_id: fixture.slide,
561 exercise_task_submissions: vec![answer],
562 },
563 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
564 file_store,
565 &init_app_conf().expect("app conf"),
566 )
567 .await
568 }
569
570 async fn slide_submission_count(conn: &mut PgConnection, exercise: Uuid, user: Uuid) -> u32 {
571 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_and_user_ids(conn, exercise, user)
572 .await
573 .expect("count")
574 }
575
576 async fn answer_kind_of(conn: &mut PgConnection, submission: Uuid) -> AnswerKind {
577 models::exercise_task_submissions::get_answer_kind(conn, submission)
578 .await
579 .expect("answer kind")
580 }
581
582 async fn recorded_files(conn: &mut PgConnection, submission: Uuid) -> Vec<(Uuid, i32)> {
583 models::exercise_task_submission_files::get_positions_by_task_submission_id(
584 conn, submission,
585 )
586 .await
587 .expect("submission files")
588 }
589
590 async fn binding_id_of(conn: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
591 models::exercise_answer_uploads::get_id_by_file_upload_id(conn, file_upload_id)
592 .await
593 .expect("binding id")
594 }
595
596 async fn reap(conn: &mut PgConnection, file_upload_id: Uuid) {
597 models::exercise_answer_uploads::delete_by_file_upload_id(conn, file_upload_id)
598 .await
599 .expect("reap");
600 }
601
602 fn stub_grading() -> ExerciseTaskGradingResult {
603 ExerciseTaskGradingResult {
604 grading_progress: GradingProgress::FullyGraded,
605 score_given: 1.0,
606 score_maximum: 1,
607 feedback_text: Some("graded by the stub".to_string()),
608 feedback_json: None,
609 set_user_variables: None,
610 }
611 }
612
613 struct StubState {
614 grade_requests: Mutex<Vec<serde_json::Value>>,
615 hold_grading: Option<Arc<tokio::sync::Semaphore>>,
618 }
619
620 async fn stub_grade(
621 state: web::Data<StubState>,
622 body: web::Json<serde_json::Value>,
623 ) -> HttpResponse {
624 state
625 .grade_requests
626 .lock()
627 .expect("stub lock")
628 .push(body.into_inner());
629 if let Some(hold) = &state.hold_grading {
630 hold.acquire().await.expect("hold permit").forget();
631 }
632 HttpResponse::Ok().json(stub_grading())
633 }
634
635 fn start_exercise_service_stub(state: Arc<StubState>) -> String {
639 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
640 let port = listener.local_addr().expect("local addr").port();
641 let server = actix_web::HttpServer::new(move || {
642 actix_web::App::new()
643 .app_data(web::Data::from(state.clone()))
644 .route("/grade", web::post().to(stub_grade))
645 })
646 .workers(1)
647 .disable_signals()
648 .listen(listener)
649 .expect("listen")
650 .run();
651 actix_web::rt::spawn(server);
652 format!("http://127.0.0.1:{port}")
653 }
654
655 async fn assert_rejected_without_a_submission(
658 conn: &mut PgConnection,
659 fixture: &Fixture,
660 answer: StudentExerciseTaskSubmission,
661 expected_message_key: &str,
662 ) {
663 let store = temp_file_store();
664 let error = submit(conn, fixture, answer, &store)
665 .await
666 .expect_err("the submit must be rejected");
667 assert_eq!(message_key_of(&error), expected_message_key);
668 assert_eq!(
669 slide_submission_count(conn, fixture.exercise, fixture.user).await,
670 0
671 );
672 }
673
674 macro_rules! fixture {
675 ($tx:ident, $fixture:ident) => {
676 fixture!($tx, $fixture, _state);
677 };
678 ($tx:ident, $fixture:ident, $state:ident) => {
679 let $state = Arc::new(StubState {
680 grade_requests: Mutex::new(Vec::new()),
681 hold_grading: None,
682 });
683 let url = start_exercise_service_stub($state.clone());
684 insert_data!(:$tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
685 let task = insert_graded_task($tx.as_mut(), slide, url, 1).await;
686 enroll_and_open($tx.as_mut(), user, course, instance.id, exercise, slide).await;
687 let $fixture = Fixture {
688 user,
689 course,
690 chapter,
691 page,
692 exercise,
693 slide,
694 task,
695 };
696 };
697 }
698
699 #[actix_web::test]
700 async fn naming_another_users_upload_is_rejected_and_creates_no_submission() {
701 fixture!(tx, fixture);
702 let stranger = models::users::insert(
703 tx.as_mut(),
704 models::PKeyPolicy::Generate,
705 &format!("{}@example.com", Uuid::new_v4()),
706 None,
707 None,
708 )
709 .await
710 .expect("stranger");
711 let theirs = bind_upload(tx.as_mut(), fixture.exercise, stranger, "theirs.txt").await;
712
713 assert_rejected_without_a_submission(
714 tx.as_mut(),
715 &fixture,
716 file_answer(fixture.task, vec![theirs]),
717 "unknown_upload",
718 )
719 .await;
720 tx.rollback().await;
721 }
722
723 #[actix_web::test]
724 async fn naming_another_exercises_upload_is_rejected_and_creates_no_submission() {
725 fixture!(tx, fixture);
726 let other_exercise = models::exercises::insert(
727 tx.as_mut(),
728 models::PKeyPolicy::Generate,
729 fixture.course,
730 "other",
731 fixture.page,
732 fixture.chapter,
733 1,
734 )
735 .await
736 .expect("second exercise");
737 let elsewhere =
738 bind_upload(tx.as_mut(), other_exercise, fixture.user, "elsewhere.txt").await;
739
740 assert_rejected_without_a_submission(
741 tx.as_mut(),
742 &fixture,
743 file_answer(fixture.task, vec![elsewhere]),
744 "unknown_upload",
745 )
746 .await;
747 tx.rollback().await;
748 }
749
750 #[actix_web::test]
751 async fn naming_the_same_upload_twice_is_rejected() {
752 fixture!(tx, fixture);
753 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "once.txt").await;
754
755 assert_rejected_without_a_submission(
756 tx.as_mut(),
757 &fixture,
758 file_answer(fixture.task, vec![file, file]),
759 "duplicate_upload",
760 )
761 .await;
762 tx.rollback().await;
763 }
764
765 #[actix_web::test]
769 async fn naming_the_same_upload_from_two_different_tasks_is_rejected() {
770 fixture!(tx, fixture, state);
771 let other_task = insert_graded_task(
772 tx.as_mut(),
773 fixture.slide,
774 start_exercise_service_stub(state.clone()),
775 2,
776 )
777 .await;
778 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "shared.txt").await;
779 let store = temp_file_store();
780 let exercise = models::exercises::get_by_id(tx.as_mut(), fixture.exercise)
781 .await
782 .expect("exercise");
783
784 let error = process_submission(
785 tx.as_mut(),
786 fixture.user,
787 exercise,
788 &StudentExerciseSlideSubmission {
789 exercise_slide_id: fixture.slide,
790 exercise_task_submissions: vec![
791 file_answer(fixture.task, vec![file]),
792 file_answer(other_task, vec![file]),
793 ],
794 },
795 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
796 &store,
797 &init_app_conf().expect("app conf"),
798 )
799 .await
800 .expect_err("naming the same upload from two tasks must be rejected");
801 assert_eq!(message_key_of(&error), "duplicate_upload");
802 assert_eq!(
803 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
804 0
805 );
806 assert!(
807 state.grade_requests.lock().expect("stub lock").is_empty(),
808 "the answer must be refused at the edge, before the exercise service is asked anything"
809 );
810 tx.rollback().await;
811 }
812
813 #[actix_web::test]
816 async fn a_file_answer_naming_no_files_is_refused() {
817 use actix_web::ResponseError;
818 use actix_web::http::StatusCode;
819 fixture!(tx, fixture, state);
820 let store = temp_file_store();
821
822 let error = submit(
823 tx.as_mut(),
824 &fixture,
825 file_answer(fixture.task, vec![]),
826 &store,
827 )
828 .await
829 .expect_err("a file answer naming nothing must be refused");
830 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
831 assert_eq!(
832 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
833 0
834 );
835 assert!(
836 state.grade_requests.lock().expect("stub lock").is_empty(),
837 "the answer must be refused at the edge, before the exercise service is asked anything"
838 );
839 tx.rollback().await;
840 }
841
842 #[actix_web::test]
843 async fn naming_a_reaped_upload_is_rejected_as_expired() {
844 fixture!(tx, fixture);
845 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "gone.txt").await;
846 reap(tx.as_mut(), file).await;
847
848 assert_rejected_without_a_submission(
849 tx.as_mut(),
850 &fixture,
851 file_answer(fixture.task, vec![file]),
852 "upload_expired",
853 )
854 .await;
855 tx.rollback().await;
856 }
857
858 #[actix_web::test]
862 async fn a_legitimate_file_answer_lands_ordered_with_its_metadata() {
863 fixture!(tx, fixture);
864 let first = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "first.txt").await;
865 let second = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "second.txt").await;
866 let named = vec![second, first];
867 let store = temp_file_store();
868
869 let result = submit(
870 tx.as_mut(),
871 &fixture,
872 file_answer(fixture.task, named.clone()),
873 &store,
874 )
875 .await
876 .expect("the submit must be accepted");
877
878 let submission = result
879 .exercise_task_submission_results
880 .into_iter()
881 .next()
882 .expect("one task submission")
883 .submission;
884 assert_eq!(
885 answer_kind_of(tx.as_mut(), submission.id).await,
886 AnswerKind::File
887 );
888 assert_eq!(
889 recorded_files(tx.as_mut(), submission.id).await,
890 vec![(second, 0), (first, 1)]
891 );
892 assert_eq!(submission.answer_kind, AnswerKind::File);
893 let files = submission
894 .data_files
895 .expect("a file answer comes back with its files");
896 assert_eq!(
897 files.iter().map(|file| file.id).collect::<Vec<_>>(),
898 named,
899 "the plugin's order is the answer, not ours to sort"
900 );
901 assert_eq!(
902 files
903 .iter()
904 .map(|file: &AnswerFile| file.name.as_str())
905 .collect::<Vec<_>>(),
906 vec!["second.txt", "first.txt"]
907 );
908 assert_eq!(
909 submission.data_json,
910 Some(serde_json::json!({ "plugin": "said so" }))
911 );
912 tx.rollback().await;
913 }
914
915 #[actix_web::test]
922 async fn a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap() {
923 let hold = Arc::new(tokio::sync::Semaphore::new(0));
924 let state = Arc::new(StubState {
925 grade_requests: Mutex::new(Vec::new()),
926 hold_grading: Some(hold.clone()),
927 });
928 let url = start_exercise_service_stub(state.clone());
929
930 insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
933 let task = insert_graded_task(tx.as_mut(), slide, url, 1).await;
934 enroll_and_open(tx.as_mut(), user, course, instance.id, exercise, slide).await;
935 let file = bind_upload(tx.as_mut(), exercise, user, "raced.txt").await;
936 let binding = binding_id_of(tx.as_mut(), file).await;
937 tx.commit().await;
938
939 let fixture = Fixture {
940 user,
941 course,
942 chapter,
943 page,
944 exercise,
945 slide,
946 task,
947 };
948 let submitting = actix_web::rt::spawn(async move {
949 let mut conn = PgConnection::connect(&test_database_url())
950 .await
951 .expect("submit connection");
952 let store = temp_file_store();
953 submit(
954 &mut conn,
955 &fixture,
956 file_answer(fixture.task, vec![file]),
957 &store,
958 )
959 .await
960 .map(|result| {
961 result
962 .exercise_task_submission_results
963 .into_iter()
964 .next()
965 .expect("one task submission")
966 .submission
967 .id
968 })
969 });
970
971 for _ in 0..100 {
973 if !state.grade_requests.lock().expect("stub lock").is_empty() {
974 break;
975 }
976 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
977 }
978 assert_eq!(
979 state.grade_requests.lock().expect("stub lock").len(),
980 1,
981 "the submit must reach the grading hop, which is what holds its transaction open"
982 );
983
984 let mut reaper_conn = PgConnection::connect(&test_database_url())
985 .await
986 .expect("reaper connection");
987 let outcome = {
989 let mut reaping = std::pin::pin!(models::exercise_answer_uploads::mark_reaped(
990 &mut reaper_conn,
991 binding
992 ));
993 assert!(
994 tokio::time::timeout(std::time::Duration::from_millis(500), &mut reaping)
995 .await
996 .is_err(),
997 "the reaper must block on the row lock the submit holds, not decide without it"
998 );
999
1000 hold.add_permits(1);
1001 let submission = submitting
1002 .await
1003 .expect("the submit task must not panic")
1004 .expect("the submit must be accepted");
1005
1006 let reaped = tokio::time::timeout(std::time::Duration::from_secs(10), &mut reaping)
1007 .await
1008 .expect("the reaper must unblock once the submit commits")
1009 .expect("mark_reaped");
1010 (reaped, submission)
1011 };
1012 let (reaped, submission) = outcome;
1013 assert!(
1014 !reaped,
1015 "the reaper must decline an upload the submit referenced while it waited"
1016 );
1017
1018 let mut check_conn = Conn::init().await;
1019 let mut check_tx = check_conn.begin().await;
1020 assert_eq!(
1021 recorded_files(check_tx.as_mut(), submission).await,
1022 vec![(file, 0)],
1023 "the submission must keep the file the reaper tried to take"
1024 );
1025 assert_eq!(
1026 models::exercise_answer_uploads::get_for_exercise_and_user(
1027 check_tx.as_mut(),
1028 exercise,
1029 user,
1030 &[file]
1031 )
1032 .await
1033 .expect("binding lookup"),
1034 vec![models::exercise_answer_uploads::AnswerUpload {
1035 file_upload_id: file,
1036 deleted: false
1037 }],
1038 "the upload must stay usable, so a download can still serve it"
1039 );
1040 check_tx.rollback().await;
1041 }
1042}