1use crate::prelude::*;
11use chrono::Duration;
12
13const REAP_BATCH_LIMIT: i64 = 1000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
21#[sqlx(type_name = "answer_upload_origin", rename_all = "snake_case")]
22#[serde(rename_all = "snake_case")]
23pub enum AnswerUploadOrigin {
24 NativeClient,
25 Iframe,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AnswerUpload {
32 pub file_upload_id: Uuid,
33 pub deleted: bool,
34}
35
36pub async fn insert_many(
38 conn: &mut PgConnection,
39 exercise_id: Uuid,
40 user_id: Uuid,
41 file_upload_ids: &[Uuid],
42 origin: AnswerUploadOrigin,
43) -> ModelResult<()> {
44 sqlx::query!(
45 "
46INSERT INTO exercise_answer_uploads (file_upload_id, exercise_id, user_id, origin)
47SELECT file_upload_id,
48 $2,
49 $3,
50 $4
51FROM UNNEST($1::uuid []) AS t(file_upload_id)
52",
53 file_upload_ids,
54 exercise_id,
55 user_id,
56 origin
57 )
58 .execute(conn)
59 .await?;
60 Ok(())
61}
62
63pub async fn get_for_exercise_and_user(
69 conn: &mut PgConnection,
70 exercise_id: Uuid,
71 user_id: Uuid,
72 file_upload_ids: &[Uuid],
73) -> ModelResult<Vec<AnswerUpload>> {
74 let res = sqlx::query!(
75 "
76SELECT file_upload_id,
77 deleted_at
78FROM exercise_answer_uploads
79WHERE file_upload_id = ANY($1)
80 AND exercise_id = $2
81 AND user_id = $3
82",
83 file_upload_ids,
84 exercise_id,
85 user_id
86 )
87 .fetch_all(conn)
88 .await?;
89 Ok(res
90 .into_iter()
91 .map(|row| AnswerUpload {
92 file_upload_id: row.file_upload_id,
93 deleted: row.deleted_at.is_some(),
94 })
95 .collect())
96}
97
98pub async fn lock_for_exercise_and_user(
106 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
107 exercise_id: Uuid,
108 user_id: Uuid,
109 file_upload_ids: &[Uuid],
110) -> ModelResult<Vec<AnswerUpload>> {
111 let res = sqlx::query!(
112 "
113SELECT file_upload_id,
114 deleted_at
115FROM exercise_answer_uploads
116WHERE file_upload_id = ANY($1)
117 AND exercise_id = $2
118 AND user_id = $3
119FOR UPDATE
120",
121 file_upload_ids,
122 exercise_id,
123 user_id
124 )
125 .fetch_all(&mut **tx)
126 .await?;
127 Ok(res
128 .into_iter()
129 .map(|row| AnswerUpload {
130 file_upload_id: row.file_upload_id,
131 deleted: row.deleted_at.is_some(),
132 })
133 .collect())
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ReapableUpload {
139 pub id: Uuid,
140 pub file_upload_id: Uuid,
141 pub path: String,
143}
144
145pub async fn get_reapable(conn: &mut PgConnection) -> ModelResult<Vec<ReapableUpload>> {
165 let res = sqlx::query_as!(
166 ReapableUpload,
167 "
168SELECT u.id,
169 u.file_upload_id,
170 f.path
171FROM exercise_answer_uploads AS u
172 JOIN file_uploads AS f ON f.id = u.file_upload_id
173WHERE f.deleted_at IS NULL
174 AND u.created_at < now() - CASE
175 u.origin
176 WHEN 'native_client' THEN interval '1 hour'
177 ELSE interval '7 days'
178 END
179 AND NOT EXISTS (
180 SELECT 1
181 FROM exercise_task_submission_files AS s
182 WHERE s.file_upload_id = u.file_upload_id
183 AND s.deleted_at IS NULL
184 )
185ORDER BY u.created_at
186LIMIT $1
187",
188 REAP_BATCH_LIMIT
189 )
190 .fetch_all(conn)
191 .await?;
192 Ok(res)
193}
194
195pub async fn mark_reaped(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
212 let mut tx = conn.begin().await?;
213 let locked = sqlx::query_scalar!(
214 "
215SELECT id
216FROM exercise_answer_uploads
217WHERE id = $1
218FOR UPDATE
219",
220 id
221 )
222 .fetch_optional(&mut *tx)
223 .await?;
224 if locked.is_none() {
225 tx.rollback().await?;
226 return Ok(false);
227 }
228 let retired = sqlx::query_scalar!(
229 "
230UPDATE exercise_answer_uploads AS u
231SET deleted_at = COALESCE(u.deleted_at, now())
232WHERE u.id = $1
233 AND NOT EXISTS (
234 SELECT 1
235 FROM exercise_task_submission_files AS s
236 WHERE s.file_upload_id = u.file_upload_id
237 AND s.deleted_at IS NULL
238 )
239RETURNING u.id
240",
241 id
242 )
243 .fetch_optional(&mut *tx)
244 .await?;
245 tx.commit().await?;
246 Ok(retired.is_some())
247}
248
249pub async fn delete_by_file_upload_id(
254 conn: &mut PgConnection,
255 file_upload_id: Uuid,
256) -> ModelResult<()> {
257 sqlx::query!(
258 "
259UPDATE exercise_answer_uploads
260SET deleted_at = now()
261WHERE file_upload_id = $1
262 AND deleted_at IS NULL
263",
264 file_upload_id
265 )
266 .execute(conn)
267 .await?;
268 Ok(())
269}
270
271pub async fn get_id_by_file_upload_id(
275 conn: &mut PgConnection,
276 file_upload_id: Uuid,
277) -> ModelResult<Uuid> {
278 let id = sqlx::query_scalar!(
279 "SELECT id FROM exercise_answer_uploads WHERE file_upload_id = $1",
280 file_upload_id
281 )
282 .fetch_one(conn)
283 .await?;
284 Ok(id)
285}
286
287pub struct AnswerUploadBinding {
289 pub file_upload_id: Uuid,
290 pub exercise_id: Uuid,
291 pub user_id: Uuid,
292 pub origin: AnswerUploadOrigin,
293}
294
295pub async fn get_by_file_upload_ids(
300 conn: &mut PgConnection,
301 file_upload_ids: &[Uuid],
302) -> ModelResult<Vec<AnswerUploadBinding>> {
303 let bindings = sqlx::query_as!(
304 AnswerUploadBinding,
305 "
306SELECT file_upload_id,
307 exercise_id,
308 user_id,
309 origin
310FROM exercise_answer_uploads
311WHERE file_upload_id = ANY($1)
312 AND deleted_at IS NULL
313",
314 file_upload_ids
315 )
316 .fetch_all(conn)
317 .await?;
318 Ok(bindings)
319}
320
321pub async fn backdate(
326 conn: &mut PgConnection,
327 file_upload_id: Uuid,
328 age: Duration,
329) -> ModelResult<()> {
330 sqlx::query!(
331 "UPDATE exercise_answer_uploads SET created_at = now() - $2::interval WHERE file_upload_id = $1",
332 file_upload_id,
333 age as Duration
334 )
335 .execute(conn)
336 .await?;
337 Ok(())
338}
339
340#[cfg(test)]
341mod test {
342 use super::*;
343 use crate::library::grading::SubmittedAnswer;
344 use crate::test_helper::*;
345 use chrono::Duration;
346
347 async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
348 crate::file_uploads::insert(
349 tx,
350 name,
351 &format!("exercise-services-client/{name}"),
352 "application/octet-stream",
353 None,
354 None,
355 )
356 .await
357 .unwrap()
358 }
359
360 async fn soft_delete(tx: &mut PgConnection, file_upload_id: Uuid) {
361 crate::exercise_answer_uploads::delete_by_file_upload_id(tx, file_upload_id)
362 .await
363 .unwrap();
364 }
365
366 async fn insert_file_at(tx: &mut PgConnection, name: &str, path: &str) -> Uuid {
367 crate::file_uploads::insert(tx, name, path, "application/octet-stream", None, None)
368 .await
369 .unwrap()
370 }
371
372 async fn backdate_file(tx: &mut PgConnection, file_upload_id: Uuid, age: Duration) {
375 crate::file_uploads::backdate(tx, file_upload_id, age)
376 .await
377 .unwrap();
378 }
379
380 async fn backdate(tx: &mut PgConnection, file_upload_id: Uuid, age: Duration) {
381 backdate_file(&mut *tx, file_upload_id, age).await;
382 crate::exercise_answer_uploads::backdate(tx, file_upload_id, age)
383 .await
384 .unwrap();
385 }
386
387 async fn insert_task_submission_referencing(
390 tx: &mut PgConnection,
391 course_id: Uuid,
392 user_id: Uuid,
393 exercise_id: Uuid,
394 slide_id: Uuid,
395 task_id: Uuid,
396 file_upload_ids: &[Uuid],
397 ) -> Option<Uuid> {
398 let slide_submission = crate::exercise_slide_submissions::insert_exercise_slide_submission(
399 &mut *tx,
400 crate::exercise_slide_submissions::NewExerciseSlideSubmission {
401 exercise_slide_id: slide_id,
402 course_id: Some(course_id),
403 exam_id: None,
404 user_id,
405 exercise_id,
406 user_points_update_strategy:
407 crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
408 },
409 )
410 .await
411 .unwrap();
412 let task_submission = crate::exercise_task_submissions::insert(
413 &mut *tx,
414 PKeyPolicy::Generate,
415 slide_submission.id,
416 slide_id,
417 task_id,
418 &SubmittedAnswer::Json {
419 data: serde_json::json!({ "opaque": "plugin owned" }),
420 },
421 )
422 .await
423 .unwrap();
424 crate::exercise_task_submission_files::insert_many(
425 &mut *tx,
426 task_submission,
427 file_upload_ids,
428 )
429 .await
430 .unwrap();
431 Some(task_submission)
432 }
433
434 async fn reapable_among(tx: &mut PgConnection, of_interest: &[Uuid]) -> Vec<Uuid> {
437 get_reapable(tx)
438 .await
439 .unwrap()
440 .into_iter()
441 .map(|upload| upload.file_upload_id)
442 .filter(|file_upload_id| of_interest.contains(file_upload_id))
443 .collect()
444 }
445
446 async fn reapable_binding_of(tx: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
449 get_reapable(tx)
450 .await
451 .unwrap()
452 .into_iter()
453 .find(|upload| upload.file_upload_id == file_upload_id)
454 .expect("the reaper must list this upload")
455 .id
456 }
457
458 #[tokio::test]
459 async fn finds_only_uploads_bound_to_the_same_exercise_and_user() {
460 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:chapter_id, page:page_id, exercise:exercise_id, slide:_slide, task:_task);
461 let other_exercise = crate::exercises::insert(
462 tx.as_mut(),
463 PKeyPolicy::Generate,
464 course_id,
465 "Other",
466 page_id,
467 chapter_id,
468 1,
469 )
470 .await
471 .unwrap();
472 let other_user = crate::users::insert(
473 tx.as_mut(),
474 PKeyPolicy::Generate,
475 "other@example.com",
476 None,
477 None,
478 )
479 .await
480 .unwrap();
481
482 let mine = insert_file(tx.as_mut(), "mine").await;
483 let for_other_exercise = insert_file(tx.as_mut(), "other-exercise").await;
484 let for_other_user = insert_file(tx.as_mut(), "other-user").await;
485 insert_many(
486 tx.as_mut(),
487 exercise_id,
488 user_id,
489 &[mine],
490 AnswerUploadOrigin::NativeClient,
491 )
492 .await
493 .unwrap();
494 insert_many(
495 tx.as_mut(),
496 other_exercise,
497 user_id,
498 &[for_other_exercise],
499 AnswerUploadOrigin::NativeClient,
500 )
501 .await
502 .unwrap();
503 insert_many(
504 tx.as_mut(),
505 exercise_id,
506 other_user,
507 &[for_other_user],
508 AnswerUploadOrigin::NativeClient,
509 )
510 .await
511 .unwrap();
512
513 let found = get_for_exercise_and_user(
514 tx.as_mut(),
515 exercise_id,
516 user_id,
517 &[mine, for_other_exercise, for_other_user],
518 )
519 .await
520 .unwrap();
521 assert_eq!(
522 found,
523 vec![AnswerUpload {
524 file_upload_id: mine,
525 deleted: false
526 }]
527 );
528 tx.rollback().await;
529 }
530
531 #[tokio::test]
533 async fn a_soft_deleted_upload_is_still_found_and_flagged() {
534 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
535 let file_id = insert_file(tx.as_mut(), "reaped").await;
536 insert_many(
537 tx.as_mut(),
538 exercise_id,
539 user_id,
540 &[file_id],
541 AnswerUploadOrigin::NativeClient,
542 )
543 .await
544 .unwrap();
545 soft_delete(tx.as_mut(), file_id).await;
546
547 let found = get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
548 .await
549 .unwrap();
550 assert_eq!(
551 found,
552 vec![AnswerUpload {
553 file_upload_id: file_id,
554 deleted: true
555 }]
556 );
557 tx.rollback().await;
558 }
559
560 #[tokio::test]
561 async fn an_unrecorded_id_is_not_found() {
562 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
563 let found = get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[Uuid::new_v4()])
564 .await
565 .unwrap();
566 assert!(found.is_empty());
567 assert!(
568 get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[])
569 .await
570 .unwrap()
571 .is_empty()
572 );
573 tx.rollback().await;
574 }
575
576 #[tokio::test]
577 async fn inserting_an_empty_list_records_nothing() {
578 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
579 insert_many(
580 tx.as_mut(),
581 exercise_id,
582 user_id,
583 &[],
584 AnswerUploadOrigin::NativeClient,
585 )
586 .await
587 .unwrap();
588 tx.rollback().await;
589 }
590
591 #[tokio::test]
592 async fn reaps_only_uploads_past_the_retention_window() {
593 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
594 let just_under = insert_file(tx.as_mut(), "just-under").await;
595 let just_over = insert_file(tx.as_mut(), "just-over").await;
596 insert_many(
597 tx.as_mut(),
598 exercise_id,
599 user_id,
600 &[just_under, just_over],
601 AnswerUploadOrigin::NativeClient,
602 )
603 .await
604 .unwrap();
605 backdate(tx.as_mut(), just_under, Duration::minutes(59)).await;
606 backdate(tx.as_mut(), just_over, Duration::minutes(61)).await;
607
608 assert_eq!(
609 reapable_among(tx.as_mut(), &[just_under, just_over]).await,
610 vec![just_over]
611 );
612 tx.rollback().await;
613 }
614
615 #[tokio::test]
618 async fn a_fully_reaped_upload_is_not_reaped_again() {
619 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
620 let file_id = insert_file(tx.as_mut(), "old").await;
621 insert_many(
622 tx.as_mut(),
623 exercise_id,
624 user_id,
625 &[file_id],
626 AnswerUploadOrigin::NativeClient,
627 )
628 .await
629 .unwrap();
630 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
631 let id = reapable_binding_of(tx.as_mut(), file_id).await;
632 assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
633 crate::file_uploads::delete_and_fetch_path(tx.as_mut(), file_id)
635 .await
636 .unwrap();
637
638 assert!(reapable_among(tx.as_mut(), &[file_id]).await.is_empty());
639 assert_eq!(
640 get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
641 .await
642 .unwrap(),
643 vec![AnswerUpload {
644 file_upload_id: file_id,
645 deleted: true
646 }]
647 );
648 tx.rollback().await;
649 }
650
651 #[tokio::test]
652 async fn an_upload_a_submission_was_made_from_is_never_reaped() {
653 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
654 let submitted = insert_file(tx.as_mut(), "submitted").await;
655 let orphan = insert_file(tx.as_mut(), "orphan").await;
656 insert_many(
657 tx.as_mut(),
658 exercise_id,
659 user_id,
660 &[submitted, orphan],
661 AnswerUploadOrigin::NativeClient,
662 )
663 .await
664 .unwrap();
665 backdate(tx.as_mut(), submitted, Duration::hours(2)).await;
666 backdate(tx.as_mut(), orphan, Duration::hours(2)).await;
667
668 insert_task_submission_referencing(
669 tx.as_mut(),
670 course_id,
671 user_id,
672 exercise_id,
673 slide_id,
674 task_id,
675 &[submitted],
676 )
677 .await;
678
679 assert_eq!(
680 reapable_among(tx.as_mut(), &[submitted, orphan]).await,
681 vec![orphan]
682 );
683 tx.rollback().await;
684 }
685
686 #[tokio::test]
690 async fn unbound_uploads_are_never_reaped() {
691 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
692 let org_id = crate::organizations::all_organizations(tx.as_mut())
693 .await
694 .unwrap()[0]
695 .id;
696
697 let unbound_answer =
698 insert_file_at(tx.as_mut(), "answer.tar.zst", "tmc/AbCdEfGhIjKlMnOp").await;
699 let cms_media = insert_file_at(
700 tx.as_mut(),
701 "lecture.pdf",
702 &format!("course/{course_id}/files/qRsTuVwXyZ"),
703 )
704 .await;
705 let organization_image = insert_file_at(
706 tx.as_mut(),
707 "logo.png",
708 &format!("organization/{org_id}/images/aBcDeFgHiJ"),
709 )
710 .await;
711 let bound_upload = insert_file(tx.as_mut(), "orphan").await;
712 insert_many(
713 tx.as_mut(),
714 exercise_id,
715 user_id,
716 &[bound_upload],
717 AnswerUploadOrigin::NativeClient,
718 )
719 .await
720 .unwrap();
721 backdate(tx.as_mut(), bound_upload, Duration::hours(2)).await;
722 for foreign in [unbound_answer, cms_media, organization_image] {
723 backdate_file(tx.as_mut(), foreign, Duration::hours(2)).await;
724 }
725
726 assert_eq!(
727 reapable_among(
728 tx.as_mut(),
729 &[unbound_answer, cms_media, organization_image, bound_upload]
730 )
731 .await,
732 vec![bound_upload]
733 );
734 tx.rollback().await;
735 }
736
737 #[tokio::test]
747 async fn a_reap_cannot_retire_an_upload_a_submission_just_referenced() {
748 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
749 let file_id = insert_file(tx.as_mut(), "raced").await;
750 insert_many(
751 tx.as_mut(),
752 exercise_id,
753 user_id,
754 &[file_id],
755 AnswerUploadOrigin::NativeClient,
756 )
757 .await
758 .unwrap();
759 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
760
761 let binding = reapable_binding_of(tx.as_mut(), file_id).await;
763
764 let task_submission = insert_task_submission_referencing(
766 tx.as_mut(),
767 course_id,
768 user_id,
769 exercise_id,
770 slide_id,
771 task_id,
772 &[file_id],
773 )
774 .await;
775 assert!(task_submission.is_some());
776
777 assert!(
778 !mark_reaped(tx.as_mut(), binding).await.unwrap(),
779 "the reaper must abandon an upload that became referenced after it was listed"
780 );
781 assert_eq!(
782 get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
783 .await
784 .unwrap(),
785 vec![AnswerUpload {
786 file_upload_id: file_id,
787 deleted: false
788 }],
789 "the upload must stay usable, so download_submission can still serve it"
790 );
791 tx.rollback().await;
792 }
793
794 #[tokio::test]
797 async fn a_locked_lookup_reports_an_upload_the_reaper_already_retired() {
798 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
799 let file_id = insert_file(tx.as_mut(), "reaped-first").await;
800 insert_many(
801 tx.as_mut(),
802 exercise_id,
803 user_id,
804 &[file_id],
805 AnswerUploadOrigin::NativeClient,
806 )
807 .await
808 .unwrap();
809 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
810 let binding = reapable_binding_of(tx.as_mut(), file_id).await;
811 assert!(mark_reaped(tx.as_mut(), binding).await.unwrap());
812
813 let mut inner = tx.begin().await;
814 let locked = lock_for_exercise_and_user(inner.as_mut(), exercise_id, user_id, &[file_id])
815 .await
816 .unwrap();
817 assert_eq!(
818 locked,
819 vec![AnswerUpload {
820 file_upload_id: file_id,
821 deleted: true
822 }]
823 );
824 inner.rollback().await;
825 tx.rollback().await;
826 }
827
828 #[tokio::test]
831 async fn mark_reaped_is_idempotent_so_a_failed_object_delete_can_be_retried() {
832 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
833 let file_id = insert_file(tx.as_mut(), "retry").await;
834 insert_many(
835 tx.as_mut(),
836 exercise_id,
837 user_id,
838 &[file_id],
839 AnswerUploadOrigin::NativeClient,
840 )
841 .await
842 .unwrap();
843 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
844 let id = reapable_binding_of(tx.as_mut(), file_id).await;
845
846 assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
847 assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
848 assert_eq!(reapable_among(tx.as_mut(), &[file_id]).await, vec![file_id]);
850 tx.rollback().await;
851 }
852
853 #[tokio::test]
856 async fn an_iframe_upload_outlives_the_native_client_window() {
857 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
858 let native = insert_file(tx.as_mut(), "native").await;
859 let iframe = insert_file(tx.as_mut(), "iframe").await;
860 insert_many(
861 tx.as_mut(),
862 exercise_id,
863 user_id,
864 &[native],
865 AnswerUploadOrigin::NativeClient,
866 )
867 .await
868 .unwrap();
869 insert_many(
870 tx.as_mut(),
871 exercise_id,
872 user_id,
873 &[iframe],
874 AnswerUploadOrigin::Iframe,
875 )
876 .await
877 .unwrap();
878 backdate(tx.as_mut(), native, Duration::hours(2)).await;
879 backdate(tx.as_mut(), iframe, Duration::hours(2)).await;
880
881 assert_eq!(
882 reapable_among(tx.as_mut(), &[native, iframe]).await,
883 vec![native]
884 );
885 tx.rollback().await;
886 }
887
888 #[tokio::test]
889 async fn an_iframe_upload_past_seven_days_is_reaped() {
890 insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
891 let file_id = insert_file(tx.as_mut(), "stale-iframe").await;
892 insert_many(
893 tx.as_mut(),
894 exercise_id,
895 user_id,
896 &[file_id],
897 AnswerUploadOrigin::Iframe,
898 )
899 .await
900 .unwrap();
901 backdate(tx.as_mut(), file_id, Duration::days(8)).await;
902
903 assert_eq!(reapable_among(tx.as_mut(), &[file_id]).await, vec![file_id]);
904 tx.rollback().await;
905 }
906
907 #[tokio::test]
910 async fn a_submitted_iframe_upload_is_spared_past_its_window() {
911 insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
912 let submitted = insert_file(tx.as_mut(), "submitted-iframe").await;
913 let orphan = insert_file(tx.as_mut(), "orphan-iframe").await;
914 insert_many(
915 tx.as_mut(),
916 exercise_id,
917 user_id,
918 &[submitted, orphan],
919 AnswerUploadOrigin::Iframe,
920 )
921 .await
922 .unwrap();
923 backdate(tx.as_mut(), submitted, Duration::days(8)).await;
924 backdate(tx.as_mut(), orphan, Duration::days(8)).await;
925
926 insert_task_submission_referencing(
927 tx.as_mut(),
928 course_id,
929 user_id,
930 exercise_id,
931 slide_id,
932 task_id,
933 &[submitted],
934 )
935 .await;
936
937 assert_eq!(
938 reapable_among(tx.as_mut(), &[submitted, orphan]).await,
939 vec![orphan]
940 );
941 tx.rollback().await;
942 }
943}