Skip to main content

headless_lms_models/
exercise_task_submission_files.rs

1//! Which host-stored files a task submission was made from.
2//!
3//! The exercise service's answer is an opaque blob, so the host cannot recover a submission's
4//! files by reading it. Recording the association at submit time is what lets the host serve
5//! those files back without interpreting any plugin's answer shape.
6
7use crate::prelude::*;
8
9/// One file of a task submission, joined with the `file_uploads` row it points at.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SubmissionFile {
12    pub exercise_task_submission_id: Uuid,
13    pub file_upload_id: Uuid,
14    pub name: String,
15    pub path: String,
16    pub mime: String,
17    /// `None` for a file stored before the size was recorded.
18    pub size_bytes: Option<i64>,
19    pub order_number: i32,
20}
21
22/// Records the files a task submission was made from, in the order the client sent them.
23pub async fn insert_many(
24    conn: &mut PgConnection,
25    exercise_task_submission_id: Uuid,
26    file_upload_ids: &[Uuid],
27) -> ModelResult<()> {
28    sqlx::query!(
29        "
30INSERT INTO exercise_task_submission_files (
31    exercise_task_submission_id,
32    file_upload_id,
33    order_number
34  )
35SELECT $1,
36  file_upload_id,
37  (ordinality - 1)::integer
38FROM UNNEST($2::uuid []) WITH ORDINALITY AS t(file_upload_id, ordinality)
39",
40        exercise_task_submission_id,
41        file_upload_ids
42    )
43    .execute(conn)
44    .await?;
45    Ok(())
46}
47
48/// The files of the given task submissions, ordered by submission and then by the order the
49/// client sent them in. Soft-deleted files are omitted, so a reaped upload simply disappears
50/// from the listing.
51pub async fn get_by_task_submission_ids(
52    conn: &mut PgConnection,
53    exercise_task_submission_ids: &[Uuid],
54) -> ModelResult<Vec<SubmissionFile>> {
55    let res = sqlx::query_as!(
56        SubmissionFile,
57        "
58SELECT etsf.exercise_task_submission_id,
59  etsf.file_upload_id,
60  fu.name,
61  fu.path,
62  fu.mime,
63  fu.size_bytes,
64  etsf.order_number
65FROM exercise_task_submission_files AS etsf
66  JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
67WHERE etsf.exercise_task_submission_id = ANY($1)
68  AND etsf.deleted_at IS NULL
69  AND fu.deleted_at IS NULL
70ORDER BY etsf.exercise_task_submission_id,
71  etsf.order_number
72",
73        exercise_task_submission_ids
74    )
75    .fetch_all(conn)
76    .await?;
77    Ok(res)
78}
79
80/// One file of one file-typed answer to an exercise, for the teacher bulk download.
81///
82/// Deliberately carries no `file_uploads.name`: archive entries are named positionally from
83/// `order_number` and `mime` so that a plugin which anonymizes filenames cannot leak real ones
84/// through the export.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ExerciseAnswerFile {
87    pub user_id: Uuid,
88    pub exercise_task_submission_id: Uuid,
89    pub created_at: DateTime<Utc>,
90    pub path: String,
91    pub mime: String,
92    pub order_number: i32,
93}
94
95/// Every file of every file-typed answer to the exercise, ordered by user, then submission time,
96/// then the order the client sent the files in.
97///
98/// JSON-typed answers contribute nothing, and a soft delete at any layer -- slide submission, task
99/// submission, link row or file upload -- removes the file from the result.
100pub async fn get_answer_files_by_exercise_id(
101    conn: &mut PgConnection,
102    exercise_id: Uuid,
103) -> ModelResult<Vec<ExerciseAnswerFile>> {
104    let res = sqlx::query_as!(
105        ExerciseAnswerFile,
106        "
107SELECT ess.user_id,
108  ets.id AS exercise_task_submission_id,
109  ets.created_at,
110  fu.path,
111  fu.mime,
112  etsf.order_number
113FROM exercise_task_submissions AS ets
114  JOIN exercise_slide_submissions AS ess ON ets.exercise_slide_submission_id = ess.id
115  JOIN exercise_task_submission_files AS etsf ON etsf.exercise_task_submission_id = ets.id
116  JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
117WHERE ess.exercise_id = $1
118  AND ets.answer_kind = 'file'
119  AND ets.deleted_at IS NULL
120  AND ess.deleted_at IS NULL
121  AND etsf.deleted_at IS NULL
122  AND fu.deleted_at IS NULL
123ORDER BY ess.user_id,
124  ets.created_at,
125  etsf.order_number
126",
127        exercise_id
128    )
129    .fetch_all(conn)
130    .await?;
131    Ok(res)
132}
133
134/// Whether the exercise has at least one file-typed answer whose files still exist.
135///
136/// Matches the rows [`get_answer_files_by_exercise_id`] would return, without reading them: the
137/// caller only decides whether to offer the bulk download.
138pub async fn exercise_has_answer_files(
139    conn: &mut PgConnection,
140    exercise_id: Uuid,
141) -> ModelResult<bool> {
142    let res = sqlx::query!(
143        r#"
144SELECT EXISTS (
145    SELECT 1
146    FROM exercise_task_submissions AS ets
147      JOIN exercise_slide_submissions AS ess ON ets.exercise_slide_submission_id = ess.id
148      JOIN exercise_task_submission_files AS etsf ON etsf.exercise_task_submission_id = ets.id
149      JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
150    WHERE ess.exercise_id = $1
151      AND ets.answer_kind = 'file'
152      AND ets.deleted_at IS NULL
153      AND ess.deleted_at IS NULL
154      AND etsf.deleted_at IS NULL
155      AND fu.deleted_at IS NULL
156  ) AS "exists!"
157"#,
158        exercise_id
159    )
160    .fetch_one(conn)
161    .await?;
162    Ok(res.exists)
163}
164
165/// The files a submission recorded, paired with their positions, in stored order.
166///
167/// Reports the link rows as they stand; [`get_by_task_submission_ids`] instead joins `file_uploads`
168/// and so omits files whose upload row was deleted.
169pub async fn get_positions_by_task_submission_id(
170    conn: &mut PgConnection,
171    task_submission_id: Uuid,
172) -> ModelResult<Vec<(Uuid, i32)>> {
173    let rows = sqlx::query!(
174        "
175SELECT file_upload_id,
176  order_number
177FROM exercise_task_submission_files
178WHERE exercise_task_submission_id = $1
179  AND deleted_at IS NULL
180ORDER BY order_number
181",
182        task_submission_id
183    )
184    .fetch_all(conn)
185    .await?;
186    Ok(rows
187        .into_iter()
188        .map(|row| (row.file_upload_id, row.order_number))
189        .collect())
190}
191
192/// Soft-deletes one submission-to-file link, leaving both the submission and the upload alone.
193pub async fn delete_by_task_submission_and_file_upload_ids(
194    conn: &mut PgConnection,
195    task_submission_id: Uuid,
196    file_upload_id: Uuid,
197) -> ModelResult<()> {
198    sqlx::query!(
199        "
200UPDATE exercise_task_submission_files
201SET deleted_at = now()
202WHERE exercise_task_submission_id = $1
203  AND file_upload_id = $2
204  AND deleted_at IS NULL
205",
206        task_submission_id,
207        file_upload_id
208    )
209    .execute(conn)
210    .await?;
211    Ok(())
212}
213
214#[cfg(test)]
215mod test {
216    use super::*;
217    use crate::exercise_slide_submissions::{
218        NewExerciseSlideSubmission, insert_exercise_slide_submission,
219    };
220    use crate::exercise_task_gradings::UserPointsUpdateStrategy;
221    use crate::library::grading::SubmittedAnswer;
222    use crate::test_helper::*;
223
224    async fn insert_task_submission(
225        tx: &mut PgConnection,
226        course_id: Uuid,
227        user_id: Uuid,
228        exercise_id: Uuid,
229        exercise_slide_id: Uuid,
230        exercise_task_id: Uuid,
231    ) -> Uuid {
232        let slide_submission = insert_exercise_slide_submission(
233            tx,
234            NewExerciseSlideSubmission {
235                exercise_slide_id,
236                course_id: Some(course_id),
237                exam_id: None,
238                user_id,
239                exercise_id,
240                user_points_update_strategy:
241                    UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
242            },
243        )
244        .await
245        .unwrap();
246        crate::exercise_task_submissions::insert(
247            tx,
248            PKeyPolicy::Generate,
249            slide_submission.id,
250            exercise_slide_id,
251            exercise_task_id,
252            &SubmittedAnswer::Json {
253                data: serde_json::json!({ "opaque": "plugin owned" }),
254            },
255        )
256        .await
257        .unwrap()
258    }
259
260    async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
261        crate::file_uploads::insert(
262            tx,
263            name,
264            &format!("tmc/{name}"),
265            "application/octet-stream",
266            None,
267            None,
268        )
269        .await
270        .unwrap()
271    }
272
273    async fn insert_file_answer(
274        tx: &mut PgConnection,
275        course_id: Uuid,
276        user_id: Uuid,
277        exercise_id: Uuid,
278        exercise_slide_id: Uuid,
279        exercise_task_id: Uuid,
280        file_upload_ids: &[Uuid],
281    ) -> (Uuid, Uuid) {
282        let slide_submission = insert_exercise_slide_submission(
283            tx,
284            NewExerciseSlideSubmission {
285                exercise_slide_id,
286                course_id: Some(course_id),
287                exam_id: None,
288                user_id,
289                exercise_id,
290                user_points_update_strategy:
291                    UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
292            },
293        )
294        .await
295        .unwrap();
296        let submission_id = crate::exercise_task_submissions::insert(
297            tx,
298            PKeyPolicy::Generate,
299            slide_submission.id,
300            exercise_slide_id,
301            exercise_task_id,
302            &SubmittedAnswer::File {
303                file_upload_ids: file_upload_ids.to_vec(),
304                metadata: None,
305            },
306        )
307        .await
308        .unwrap();
309        (slide_submission.id, submission_id)
310    }
311
312    /// Every submission in one test transaction shares `now()`, so submission time has to be set
313    /// explicitly for the ordering to be observable at all.
314    async fn set_submitted_at(tx: &mut PgConnection, submission_id: Uuid, seconds: i64) {
315        crate::exercise_task_submissions::shift_created_at(
316            tx,
317            submission_id,
318            chrono::Duration::seconds(seconds),
319        )
320        .await
321        .unwrap();
322    }
323
324    #[tokio::test]
325    async fn returns_file_answers_ordered_by_user_then_submission_then_position() {
326        insert_data!(:tx, user:first_user, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
327        let second_user = crate::users::insert(
328            tx.as_mut(),
329            PKeyPolicy::Generate,
330            "ordering@example.org",
331            None,
332            None,
333        )
334        .await
335        .unwrap();
336        let (lesser_user, greater_user) = if first_user < second_user {
337            (first_user, second_user)
338        } else {
339            (second_user, first_user)
340        };
341
342        let later_first = insert_file(tx.as_mut(), "later-first.png").await;
343        let later_second = insert_file(tx.as_mut(), "later-second.png").await;
344        let earlier = insert_file(tx.as_mut(), "earlier.png").await;
345        let other_user_file = insert_file(tx.as_mut(), "other-user.png").await;
346
347        let (_, later) = insert_file_answer(
348            tx.as_mut(),
349            course_id,
350            lesser_user,
351            exercise_id,
352            slide_id,
353            task_id,
354            &[later_first, later_second],
355        )
356        .await;
357        set_submitted_at(tx.as_mut(), later, 60).await;
358        let (_, earlier_submission) = insert_file_answer(
359            tx.as_mut(),
360            course_id,
361            lesser_user,
362            exercise_id,
363            slide_id,
364            task_id,
365            &[earlier],
366        )
367        .await;
368        set_submitted_at(tx.as_mut(), earlier_submission, 0).await;
369        let (_, other_user_submission) = insert_file_answer(
370            tx.as_mut(),
371            course_id,
372            greater_user,
373            exercise_id,
374            slide_id,
375            task_id,
376            &[other_user_file],
377        )
378        .await;
379
380        let files = get_answer_files_by_exercise_id(tx.as_mut(), exercise_id)
381            .await
382            .unwrap();
383        assert_eq!(
384            files
385                .iter()
386                .map(|file| (
387                    file.user_id,
388                    file.exercise_task_submission_id,
389                    file.order_number
390                ))
391                .collect::<Vec<_>>(),
392            vec![
393                (lesser_user, earlier_submission, 0),
394                (lesser_user, later, 0),
395                (lesser_user, later, 1),
396                (greater_user, other_user_submission, 0),
397            ]
398        );
399        tx.rollback().await;
400    }
401
402    #[tokio::test]
403    async fn omits_json_answers() {
404        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);
405        let json_submission = insert_task_submission(
406            tx.as_mut(),
407            course_id,
408            user_id,
409            exercise_id,
410            slide_id,
411            task_id,
412        )
413        .await;
414        // A JSON answer can still have files recorded against it; only the kind decides.
415        let file_id = insert_file(tx.as_mut(), "smuggled.png").await;
416        insert_many(tx.as_mut(), json_submission, &[file_id])
417            .await
418            .unwrap();
419
420        assert!(
421            get_answer_files_by_exercise_id(tx.as_mut(), exercise_id)
422                .await
423                .unwrap()
424                .is_empty()
425        );
426        tx.rollback().await;
427    }
428
429    /// A file answer plus a second, untouched one, so a test that deletes a layer proves the
430    /// deletion is what removed the row rather than the query returning nothing at all.
431    async fn insert_two_file_answers(
432        tx: &mut PgConnection,
433        course_id: Uuid,
434        user_id: Uuid,
435        exercise_id: Uuid,
436        slide_id: Uuid,
437        task_id: Uuid,
438    ) -> (Uuid, Uuid, Uuid, Uuid) {
439        let doomed_file = insert_file(tx, "doomed.png").await;
440        let surviving_file = insert_file(tx, "surviving.png").await;
441        let (doomed_slide_submission, doomed_submission) = insert_file_answer(
442            tx,
443            course_id,
444            user_id,
445            exercise_id,
446            slide_id,
447            task_id,
448            &[doomed_file],
449        )
450        .await;
451        let (_, surviving_submission) = insert_file_answer(
452            tx,
453            course_id,
454            user_id,
455            exercise_id,
456            slide_id,
457            task_id,
458            &[surviving_file],
459        )
460        .await;
461        (
462            doomed_slide_submission,
463            doomed_submission,
464            doomed_file,
465            surviving_submission,
466        )
467    }
468
469    async fn returned_submission_ids(tx: &mut PgConnection, exercise_id: Uuid) -> Vec<Uuid> {
470        get_answer_files_by_exercise_id(tx, exercise_id)
471            .await
472            .unwrap()
473            .into_iter()
474            .map(|file| file.exercise_task_submission_id)
475            .collect()
476    }
477
478    #[tokio::test]
479    async fn omits_a_deleted_task_submission() {
480        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);
481        let (_, doomed, _, surviving) = insert_two_file_answers(
482            tx.as_mut(),
483            course_id,
484            user_id,
485            exercise_id,
486            slide_id,
487            task_id,
488        )
489        .await;
490        crate::exercise_task_submissions::delete_by_id(tx.as_mut(), doomed)
491            .await
492            .unwrap();
493
494        assert_eq!(
495            returned_submission_ids(tx.as_mut(), exercise_id).await,
496            vec![surviving]
497        );
498        tx.rollback().await;
499    }
500
501    #[tokio::test]
502    async fn omits_a_deleted_slide_submission() {
503        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);
504        let (doomed_slide_submission, doomed, _, surviving) = insert_two_file_answers(
505            tx.as_mut(),
506            course_id,
507            user_id,
508            exercise_id,
509            slide_id,
510            task_id,
511        )
512        .await;
513        crate::exercise_slide_submissions::delete_by_id(tx.as_mut(), doomed_slide_submission)
514            .await
515            .unwrap();
516
517        let returned = returned_submission_ids(tx.as_mut(), exercise_id).await;
518        assert_eq!(returned, vec![surviving]);
519        assert!(!returned.contains(&doomed));
520        tx.rollback().await;
521    }
522
523    async fn soft_delete_link_row(
524        tx: &mut PgConnection,
525        submission_id: Uuid,
526        file_upload_id: Uuid,
527    ) {
528        crate::exercise_task_submission_files::delete_by_task_submission_and_file_upload_ids(
529            tx,
530            submission_id,
531            file_upload_id,
532        )
533        .await
534        .unwrap();
535    }
536
537    #[tokio::test]
538    async fn omits_a_deleted_link_row() {
539        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);
540        let (_, doomed, doomed_file, surviving) = insert_two_file_answers(
541            tx.as_mut(),
542            course_id,
543            user_id,
544            exercise_id,
545            slide_id,
546            task_id,
547        )
548        .await;
549        soft_delete_link_row(tx.as_mut(), doomed, doomed_file).await;
550
551        assert_eq!(
552            returned_submission_ids(tx.as_mut(), exercise_id).await,
553            vec![surviving]
554        );
555        tx.rollback().await;
556    }
557
558    #[tokio::test]
559    async fn omits_a_reaped_upload() {
560        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);
561        let (_, _, doomed_file, surviving) = insert_two_file_answers(
562            tx.as_mut(),
563            course_id,
564            user_id,
565            exercise_id,
566            slide_id,
567            task_id,
568        )
569        .await;
570        crate::file_uploads::delete_and_fetch_path(tx.as_mut(), doomed_file)
571            .await
572            .unwrap();
573
574        assert_eq!(
575            returned_submission_ids(tx.as_mut(), exercise_id).await,
576            vec![surviving]
577        );
578        tx.rollback().await;
579    }
580
581    #[tokio::test]
582    async fn round_trips_files_in_the_order_the_client_sent_them() {
583        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);
584        let submission_id = insert_task_submission(
585            tx.as_mut(),
586            course_id,
587            user_id,
588            exercise_id,
589            slide_id,
590            task_id,
591        )
592        .await;
593
594        let first = insert_file(tx.as_mut(), "a.tar.zst").await;
595        let second = insert_file(tx.as_mut(), "b.tar.zst").await;
596        insert_many(tx.as_mut(), submission_id, &[first, second])
597            .await
598            .unwrap();
599
600        let files = get_by_task_submission_ids(tx.as_mut(), &[submission_id])
601            .await
602            .unwrap();
603        assert_eq!(
604            files
605                .iter()
606                .map(|f| (f.file_upload_id, f.order_number, f.name.as_str()))
607                .collect::<Vec<_>>(),
608            vec![(first, 0, "a.tar.zst"), (second, 1, "b.tar.zst")]
609        );
610        assert_eq!(files[0].path, "tmc/a.tar.zst");
611        tx.rollback().await;
612    }
613
614    /// The reaper's safety and the download endpoint both depend on this: a file uploaded for
615    /// some other submission must never appear in this submission's list.
616    #[tokio::test]
617    async fn never_returns_another_submissions_files() {
618        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);
619        let mine = insert_task_submission(
620            tx.as_mut(),
621            course_id,
622            user_id,
623            exercise_id,
624            slide_id,
625            task_id,
626        )
627        .await;
628        let theirs = insert_task_submission(
629            tx.as_mut(),
630            course_id,
631            user_id,
632            exercise_id,
633            slide_id,
634            task_id,
635        )
636        .await;
637
638        let my_file = insert_file(tx.as_mut(), "mine.tar.zst").await;
639        let their_file = insert_file(tx.as_mut(), "theirs.tar.zst").await;
640        // An upload recorded against no submission at all, e.g. an iframe or CMS upload.
641        let unrelated = insert_file(tx.as_mut(), "unrelated.png").await;
642        insert_many(tx.as_mut(), mine, &[my_file]).await.unwrap();
643        insert_many(tx.as_mut(), theirs, &[their_file])
644            .await
645            .unwrap();
646
647        let files = get_by_task_submission_ids(tx.as_mut(), &[mine])
648            .await
649            .unwrap();
650        assert_eq!(
651            files.iter().map(|f| f.file_upload_id).collect::<Vec<_>>(),
652            vec![my_file]
653        );
654        assert!(!files.iter().any(|f| f.file_upload_id == their_file));
655        assert!(!files.iter().any(|f| f.file_upload_id == unrelated));
656        tx.rollback().await;
657    }
658
659    #[tokio::test]
660    async fn omits_a_deleted_file_upload() {
661        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);
662        let submission_id = insert_task_submission(
663            tx.as_mut(),
664            course_id,
665            user_id,
666            exercise_id,
667            slide_id,
668            task_id,
669        )
670        .await;
671        let file_id = insert_file(tx.as_mut(), "reaped.tar.zst").await;
672        insert_many(tx.as_mut(), submission_id, &[file_id])
673            .await
674            .unwrap();
675
676        crate::file_uploads::delete_and_fetch_path(tx.as_mut(), file_id)
677            .await
678            .unwrap();
679        assert!(
680            get_by_task_submission_ids(tx.as_mut(), &[submission_id])
681                .await
682                .unwrap()
683                .is_empty()
684        );
685        tx.rollback().await;
686    }
687
688    /// A submission whose answer needs no files is legitimate, so an empty list must be a no-op
689    /// rather than an error.
690    #[tokio::test]
691    async fn an_empty_file_list_records_nothing() {
692        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);
693        let submission_id = insert_task_submission(
694            tx.as_mut(),
695            course_id,
696            user_id,
697            exercise_id,
698            slide_id,
699            task_id,
700        )
701        .await;
702        insert_many(tx.as_mut(), submission_id, &[]).await.unwrap();
703        assert!(
704            get_by_task_submission_ids(tx.as_mut(), &[submission_id])
705                .await
706                .unwrap()
707                .is_empty()
708        );
709        assert!(
710            get_by_task_submission_ids(tx.as_mut(), &[])
711                .await
712                .unwrap()
713                .is_empty()
714        );
715        tx.rollback().await;
716    }
717    #[tokio::test]
718    async fn reports_answer_files_only_where_a_file_answer_exists() {
719        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);
720        assert!(
721            !exercise_has_answer_files(tx.as_mut(), exercise_id)
722                .await
723                .unwrap()
724        );
725
726        let json_submission = insert_task_submission(
727            tx.as_mut(),
728            course_id,
729            user_id,
730            exercise_id,
731            slide_id,
732            task_id,
733        )
734        .await;
735        let file_id = insert_file(tx.as_mut(), "smuggled.png").await;
736        insert_many(tx.as_mut(), json_submission, &[file_id])
737            .await
738            .unwrap();
739        assert!(
740            !exercise_has_answer_files(tx.as_mut(), exercise_id)
741                .await
742                .unwrap()
743        );
744
745        let answer_file = insert_file(tx.as_mut(), "answer.png").await;
746        insert_file_answer(
747            tx.as_mut(),
748            course_id,
749            user_id,
750            exercise_id,
751            slide_id,
752            task_id,
753            &[answer_file],
754        )
755        .await;
756        assert!(
757            exercise_has_answer_files(tx.as_mut(), exercise_id)
758                .await
759                .unwrap()
760        );
761        tx.rollback().await;
762    }
763}