Skip to main content

headless_lms_server/controllers/mock_suotar/
logic.rs

1//! Per-item world-state resolution: pure functions over the working set, with `now` passed in.
2//!
3//! The caller resolves faults ahead of this, so per item the order is first matching fault, then here.
4
5use headless_lms_utils::services::suotar::SuotarEndpoint;
6
7use crate::prelude::*;
8
9use super::ids;
10use super::wire::{
11    self, ImportAttainmentResult, SuotarAttainment, SuotarResponseItem, error_item, ok_item,
12};
13use super::world::{
14    AttainmentState, DuplicateDetection, EnrolmentState, MockAttainment, MockCourseUnit,
15    MockEnrolment, MockRealisation, MockSubmission, Ripeness, SubmissionLifecycle, WorkingSet,
16    WorldWrite, person_course_key,
17};
18
19pub fn resolve_person_item(
20    item: &wire::ResolvePersonRequestItem,
21    working: &WorkingSet,
22) -> SuotarResponseItem<wire::PersonResult> {
23    let endpoint = SuotarEndpoint::ResolvePersons;
24    match working.persons.get(&item.student_number) {
25        Some(person) => ok_item(
26            &item.request_item_id,
27            "personFound",
28            wire::PersonResult {
29                // Echoed verbatim rather than re-derived, so a client that mismatches numbers shows.
30                student_number: item.student_number.clone(),
31                person_id: person.person_id.clone(),
32                first_names: person.first_names.clone(),
33                last_name: person.last_name.clone(),
34            },
35        ),
36        None => error_item(endpoint, &item.request_item_id, "personNotFound"),
37    }
38}
39
40pub fn resolve_enrolments_item(
41    item: &wire::ResolveEnrolmentRequestItem,
42    working: &mut WorkingSet,
43    now: DateTime<Utc>,
44) -> SuotarResponseItem<wire::EnrolmentResolutionResult> {
45    let endpoint = SuotarEndpoint::ResolveEnrolments;
46    let id = &item.request_item_id;
47    if !working.persons.contains_key(&item.student_number) {
48        return error_item(endpoint, id, "personNotFound");
49    }
50    let Some(course_unit) = working.course_units.get(&item.course_code).cloned() else {
51        return error_item(endpoint, id, "courseCodeNotFound");
52    };
53    // Ripening here as well as in verify keeps the two reads from contradicting each other.
54    ripen_person_course(working, &item.student_number, &item.course_code, now);
55
56    let matching: Vec<MockEnrolment> =
57        enrolments_for(working, &item.student_number, &item.course_code);
58    if matching.is_empty() {
59        return error_item(endpoint, id, "enrolmentNotFound");
60    }
61    if !matching
62        .iter()
63        .any(|enrolment| enrolment.state == EnrolmentState::Enrolled)
64    {
65        return error_item(endpoint, id, "enrolmentNotAccepted");
66    }
67
68    let mut listed: Vec<&MockEnrolment> = matching
69        .iter()
70        .filter(|enrolment| {
71            working.defaults.include_non_enrolled_in_result
72                || enrolment.state == EnrolmentState::Enrolled
73        })
74        .collect();
75    listed.sort_by_key(|enrolment| enrolment.enrolment_date_time);
76
77    let enrolments = listed
78        .into_iter()
79        .filter_map(|enrolment| {
80            course_unit
81                .realisation(&enrolment.realisation_id)
82                .map(|realisation| enrolment_dto(&course_unit, enrolment, realisation))
83        })
84        .collect();
85
86    ok_item(
87        id,
88        "enrolmentFound",
89        wire::EnrolmentResolutionResult {
90            enrolments,
91            existing_attainments: existing_attainments(
92                working,
93                &item.student_number,
94                &item.course_code,
95            ),
96        },
97    )
98}
99
100pub fn import_item(
101    item: &wire::ImportAttainmentRequestItem,
102    working: &mut WorkingSet,
103    now: DateTime<Utc>,
104) -> SuotarResponseItem<ImportAttainmentResult> {
105    let endpoint = SuotarEndpoint::ImportAttainments;
106    let id = &item.request_item_id;
107    let Some(person) = working.persons.get(&item.student_number).cloned() else {
108        return error_item(endpoint, id, "personNotFound");
109    };
110    // Import's error list has no `courseCodeNotFound`, so an unknown course code degrades here.
111    let Some(course_unit) = working.course_units.get(&item.course_code).cloned() else {
112        return error_item(endpoint, id, "enrolmentNotFound");
113    };
114    let enrolment = working
115        .enrolments
116        .get(&item.enrolment_id)
117        .filter(|enrolment| {
118            enrolment.student_number == item.student_number
119                && enrolment.course_code == item.course_code
120                && enrolment.state == EnrolmentState::Enrolled
121        })
122        .cloned();
123    let Some(enrolment) = enrolment else {
124        return error_item(endpoint, id, "enrolmentNotFound");
125    };
126    if !course_unit.behaviour.import_allowed {
127        return error_item(endpoint, id, "courseNotAllowed");
128    }
129    let Some(realisation) = course_unit.realisation(&enrolment.realisation_id).cloned() else {
130        return error_item(endpoint, id, "enrolmentNotFound");
131    };
132
133    let scale = working.defaults.scale(&realisation.grade_scale_id);
134    let grade_matches_scale = working
135        .defaults
136        .scale(&item.grade_scale_id)
137        .zip(scale)
138        .is_some_and(|(requested, expected)| requested.id == expected.id);
139    let grade = scale.and_then(|scale| scale.grade(&item.grade_id));
140    if !grade_matches_scale || grade.is_none() {
141        return error_item(endpoint, id, "invalidGradeForGradeScale");
142    }
143    if item.credits < realisation.credits.min || item.credits > realisation.credits.max {
144        return error_item(endpoint, id, "invalidCredits");
145    }
146    if !enrolment
147        .study_right_validity_period
148        .contains(item.attainment_date)
149    {
150        return error_item(endpoint, id, "studyRightNotValid");
151    }
152    if realisation.acceptor_person_id.is_none() {
153        return error_item(endpoint, id, "acceptorNotFound");
154    }
155    if !realisation.activity_period.contains(item.attainment_date) {
156        return error_item(endpoint, id, "sisuValidationFailed");
157    }
158
159    ripen_person_course(working, &item.student_number, &item.course_code, now);
160
161    if working.duplicate_detection_for(&item.student_number) == DuplicateDetection::Detect
162        && let Some(outcome) = duplicate_outcome(item, working, &realisation)
163    {
164        return outcome;
165    }
166
167    let attempt = working
168        .submissions_by_person_course
169        .get(&person_course_key(&item.student_number, &item.course_code))
170        .map_or(0, |ids| ids.len()) as u32
171        + 1;
172    let submitted_attainment_id = ids::submitted_attainment_id(
173        &item.student_number,
174        &item.course_code,
175        &item.enrolment_id,
176        item.attainment_date,
177        &realisation.grade_scale_id,
178        &item.grade_id,
179        item.credits,
180        attempt,
181    );
182    let ripeness = working.ripeness_for(&item.student_number);
183    let submission = MockSubmission {
184        submitted_attainment_id: submitted_attainment_id.clone(),
185        submitted_attainment_type: "AssessmentItemAttainment".to_string(),
186        student_number: item.student_number.clone(),
187        course_code: item.course_code.clone(),
188        enrolment_id: item.enrolment_id.clone(),
189        realisation_id: realisation.id.clone(),
190        person_id: person.person_id.clone(),
191        course_unit_id: course_unit.course_unit_id.clone(),
192        assessment_item_id: realisation.assessment_item_id.clone(),
193        attainment_date: item.attainment_date,
194        attainment_language: item.attainment_language.clone(),
195        grade_scale_id: realisation.grade_scale_id.clone(),
196        grade_id: item.grade_id.clone(),
197        credits: item.credits,
198        lifecycle: SubmissionLifecycle::Pending { ripeness },
199        verify_calls: 0,
200        id_disclosed_to_client: false,
201        created_at: now,
202    };
203    record_submission(working, submission);
204
205    if ripen(working, &submitted_attainment_id, now)
206        && let Some(SubmissionLifecycle::Registered { attainment_id, .. }) = working
207            .submissions
208            .get(&submitted_attainment_id)
209            .map(|submission| submission.lifecycle.clone())
210    {
211        return ok_item(
212            id,
213            "registered",
214            ImportAttainmentResult {
215                attainment: Some(attainment_reference(attainment_id)),
216                ..Default::default()
217            },
218        );
219    }
220
221    ok_item(
222        id,
223        "sent",
224        ImportAttainmentResult {
225            submitted_attainment_id: Some(submitted_attainment_id),
226            submitted_attainment_type: Some("AssessmentItemAttainment".to_string()),
227            ..Default::default()
228        },
229    )
230}
231
232pub fn verify_item(
233    item: &wire::VerifyAttainmentRequestItem,
234    working: &mut WorkingSet,
235    now: DateTime<Utc>,
236) -> SuotarResponseItem<wire::VerifyAttainmentResult> {
237    let endpoint = SuotarEndpoint::VerifyAttainments;
238    let id = &item.request_item_id;
239    // An unknown id is the "no registration evidence" case: anything else would let a client tell a
240    // typo from a not-yet, which real Sisu cannot.
241    let Some(submission) = working.submissions.get_mut(&item.submitted_attainment_id) else {
242        return error_item(endpoint, id, "notRegistered");
243    };
244    submission.verify_calls += 1;
245    working.writes.push(WorldWrite::UpsertSubmission(
246        item.submitted_attainment_id.clone(),
247    ));
248
249    ripen(working, &item.submitted_attainment_id, now);
250
251    let Some(submission) = working.submissions.get(&item.submitted_attainment_id) else {
252        return error_item(endpoint, id, "notRegistered");
253    };
254    match &submission.lifecycle {
255        SubmissionLifecycle::Registered { attainment_id, .. } => ok_item(
256            id,
257            "registered",
258            wire::VerifyAttainmentResult {
259                attainment: attainment_reference(attainment_id.clone()),
260            },
261        ),
262        SubmissionLifecycle::Misregistered { .. } => error_item(endpoint, id, "misregistered"),
263        _ => error_item(endpoint, id, "notRegistered"),
264    }
265}
266
267pub fn product_access_token_item(
268    item: &wire::ProductAccessTokenRequestItem,
269    working: &WorkingSet,
270) -> SuotarResponseItem<wire::ProductAccessTokenResult> {
271    let endpoint = SuotarEndpoint::ProductAccessTokens;
272    match working.product_tokens.get(&item.open_university_product_id) {
273        // A disabled or draft token is still `found`: refusing to build an enrolment link from one
274        // is our side's job, not Suotar's.
275        Some(token) => ok_item(
276            &item.request_item_id,
277            "found",
278            wire::ProductAccessTokenResult {
279                id: token.id.clone(),
280                access_token: token.access_token.clone(),
281                state: serde_plain(&token.state),
282                document_state: serde_plain(&token.document_state),
283            },
284        ),
285        None => error_item(
286            endpoint,
287            &item.request_item_id,
288            "productAccessTokenNotFound",
289        ),
290    }
291}
292
293pub fn list_by_course_item(
294    item: &wire::ListByCourseRequestItem,
295    working: &WorkingSet,
296) -> SuotarResponseItem<wire::EnrolmentsListedResult> {
297    let endpoint = SuotarEndpoint::ListByCourse;
298    let id = &item.request_item_id;
299    let Some(course_unit) = working.course_units.get(&item.course_code) else {
300        return error_item(endpoint, id, "courseCodeNotFound");
301    };
302    // There is no `realisationNotFound` code, so a realisation of another course folds into this one.
303    let realisation_ids: Vec<String> = match &item.course_unit_realisation_id {
304        Some(realisation_id) => {
305            if course_unit.realisation(realisation_id).is_none() {
306                return error_item(endpoint, id, "courseCodeNotFound");
307            }
308            vec![realisation_id.clone()]
309        }
310        None => course_unit
311            .realisations
312            .iter()
313            .map(|realisation| realisation.id.clone())
314            .collect(),
315    };
316
317    let mut people: Vec<wire::ListedPerson> = realisation_ids
318        .iter()
319        .filter_map(|realisation_id| working.enrolments_by_realisation.get(realisation_id))
320        .flatten()
321        .filter_map(|enrolment_id| working.enrolments.get(enrolment_id))
322        .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
323        .filter_map(|enrolment| {
324            let person = working.persons.get(&enrolment.student_number)?;
325            Some(wire::ListedPerson {
326                student_number: person.student_number.clone(),
327                person_id: person.person_id.clone(),
328                first_names: person.first_names.clone(),
329                last_name: person.last_name.clone(),
330                primary_email: person.primary_email.clone(),
331                secondary_email: person.secondary_email.clone(),
332                enrolment: wire::ListedEnrolment {
333                    id: enrolment.id.clone(),
334                    course_unit_realisation_id: enrolment.realisation_id.clone(),
335                    state: "ENROLLED".to_string(),
336                    enrolment_date_time: enrolment.enrolment_date_time,
337                },
338            })
339        })
340        .collect();
341    people.sort_by(|a, b| a.student_number.cmp(&b.student_number));
342
343    ok_item(
344        id,
345        "enrolmentsListed",
346        wire::EnrolmentsListedResult { people },
347    )
348}
349
350/// Moves every ripe submission of this person and course. Persisted wherever it is evaluated, or the
351/// next read contradicts this one.
352pub fn ripen_person_course(
353    working: &mut WorkingSet,
354    student_number: &str,
355    course_code: &str,
356    now: DateTime<Utc>,
357) {
358    let ids = working
359        .submissions_by_person_course
360        .get(&person_course_key(student_number, course_code))
361        .cloned()
362        .unwrap_or_default();
363    for id in ids {
364        ripen(working, &id, now);
365    }
366}
367
368/// Returns whether the submission moved to `Registered` in this call.
369pub fn ripen(working: &mut WorkingSet, submitted_attainment_id: &str, now: DateTime<Utc>) -> bool {
370    let Some(submission) = working.submissions.get(submitted_attainment_id) else {
371        return false;
372    };
373    let ripeness = match &submission.lifecycle {
374        SubmissionLifecycle::Pending { ripeness }
375        | SubmissionLifecycle::TimedOutButLanded { ripeness } => *ripeness,
376        _ => return false,
377    };
378    let ripe = match ripeness {
379        Ripeness::AtImport => true,
380        Ripeness::Manual => false,
381        Ripeness::AutoAfterVerifyCalls { calls } => submission.verify_calls > calls,
382    };
383    if !ripe {
384        return false;
385    }
386    register(working, submitted_attainment_id, now);
387    true
388}
389
390pub fn register(working: &mut WorkingSet, submitted_attainment_id: &str, now: DateTime<Utc>) {
391    let Some(submission) = working.submissions.get(submitted_attainment_id).cloned() else {
392        return;
393    };
394    let attainment_id = ids::final_attainment_id(submitted_attainment_id);
395    let attainment = MockAttainment::from_submission(
396        &submission,
397        &attainment_id,
398        AttainmentState::Attained,
399        &working.defaults,
400        now,
401    );
402    let key = person_course_key(&submission.student_number, &submission.course_code);
403    working
404        .attainments
405        .insert(attainment_id.clone(), attainment);
406    let index = working.attainments_by_person_course.entry(key).or_default();
407    if !index.contains(&attainment_id) {
408        index.push(attainment_id.clone());
409    }
410    if let Some(submission) = working.submissions.get_mut(submitted_attainment_id) {
411        submission.lifecycle = SubmissionLifecycle::Registered {
412            attainment_id: attainment_id.clone(),
413            registered_at: now,
414        };
415    }
416    working.writes.push(WorldWrite::UpsertSubmission(
417        submitted_attainment_id.to_string(),
418    ));
419    working
420        .writes
421        .push(WorldWrite::UpsertAttainment(attainment_id.clone()));
422    working.writes.push(WorldWrite::IndexAttainment {
423        student_number: submission.student_number.clone(),
424        course_code: submission.course_code.clone(),
425        id: attainment_id,
426    });
427}
428
429pub fn record_submission(working: &mut WorkingSet, submission: MockSubmission) {
430    let id = submission.submitted_attainment_id.clone();
431    let key = person_course_key(&submission.student_number, &submission.course_code);
432    let student_number = submission.student_number.clone();
433    let course_code = submission.course_code.clone();
434    working.submissions.insert(id.clone(), submission);
435    let index = working.submissions_by_person_course.entry(key).or_default();
436    if !index.contains(&id) {
437        index.push(id.clone());
438    }
439    working
440        .writes
441        .push(WorldWrite::UpsertSubmission(id.clone()));
442    working.writes.push(WorldWrite::IndexSubmission {
443        student_number,
444        course_code,
445        id,
446    });
447}
448
449fn duplicate_outcome(
450    item: &wire::ImportAttainmentRequestItem,
451    working: &WorkingSet,
452    realisation: &MockRealisation,
453) -> Option<SuotarResponseItem<ImportAttainmentResult>> {
454    let scale = working.defaults.scale(&realisation.grade_scale_id)?;
455    let incoming_rank = scale.grade(&item.grade_id)?.rank;
456    for attainment in attained(working, &item.student_number, &item.course_code) {
457        // Comparison stays within one scale; nothing says how two scales rank against each other.
458        if !scale.answers_to(&attainment.grade_scale_id) {
459            continue;
460        }
461        if attainment.grade_id == item.grade_id
462            && attainment.attainment_date == item.attainment_date
463        {
464            return Some(ok_item(
465                &item.request_item_id,
466                "duplicateAttainment",
467                ImportAttainmentResult {
468                    attainment: Some(attainment_summary(attainment)),
469                    ..Default::default()
470                },
471            ));
472        }
473        let existing_rank = scale
474            .grade(&attainment.grade_id)
475            .map_or(0, |grade| grade.rank);
476        if existing_rank >= incoming_rank {
477            return Some(ok_item(
478                &item.request_item_id,
479                "notImprovedAttainment",
480                ImportAttainmentResult {
481                    previous_attainment: Some(attainment_summary(attainment)),
482                    ..Default::default()
483                },
484            ));
485        }
486    }
487    None
488}
489
490fn attained<'a>(
491    working: &'a WorkingSet,
492    student_number: &str,
493    course_code: &str,
494) -> Vec<&'a MockAttainment> {
495    let mut found: Vec<&MockAttainment> = working
496        .attainments_by_person_course
497        .get(&person_course_key(student_number, course_code))
498        .into_iter()
499        .flatten()
500        .filter_map(|id| working.attainments.get(id))
501        .filter(|attainment| attainment.state == AttainmentState::Attained)
502        .collect();
503    found.sort_by(|a, b| {
504        a.attainment_date
505            .cmp(&b.attainment_date)
506            .then_with(|| a.id.cmp(&b.id))
507    });
508    found
509}
510
511fn existing_attainments(
512    working: &WorkingSet,
513    student_number: &str,
514    course_code: &str,
515) -> Vec<wire::ExistingAttainment> {
516    attained(working, student_number, course_code)
517        .into_iter()
518        .map(|attainment| wire::ExistingAttainment {
519            id: attainment.id.clone(),
520            attainment_type: attainment.attainment_type.clone(),
521            state: "ATTAINED".to_string(),
522            person_id: attainment.person_id.clone(),
523            course_unit_id: attainment.course_unit_id.clone(),
524            assessment_item_id: attainment.assessment_item_id.clone(),
525            course_unit_realisation_id: attainment.course_unit_realisation_id.clone(),
526            attainment_date: attainment.attainment_date,
527            registration_date: attainment.registration_date,
528            grade_scale_id: attainment.grade_scale_id.clone(),
529            grade_id: attainment.grade_id.clone(),
530            passed: attainment.passed,
531        })
532        .collect()
533}
534
535/// The bare `{id, type}` body that both `registered` answers carry.
536fn attainment_reference(id: String) -> SuotarAttainment {
537    SuotarAttainment {
538        id,
539        attainment_type: "CourseUnitAttainment".to_string(),
540        state: None,
541        attainment_date: None,
542        registration_date: None,
543        grade_scale_id: None,
544        grade_id: None,
545    }
546}
547
548fn attainment_summary(attainment: &MockAttainment) -> SuotarAttainment {
549    SuotarAttainment {
550        id: attainment.id.clone(),
551        attainment_type: attainment.attainment_type.clone(),
552        state: Some("ATTAINED".to_string()),
553        attainment_date: Some(attainment.attainment_date),
554        registration_date: Some(attainment.registration_date),
555        grade_scale_id: Some(attainment.grade_scale_id.clone()),
556        grade_id: Some(attainment.grade_id.clone()),
557    }
558}
559
560fn enrolments_for(
561    working: &WorkingSet,
562    student_number: &str,
563    course_code: &str,
564) -> Vec<MockEnrolment> {
565    working
566        .enrolments_by_person
567        .get(student_number)
568        .into_iter()
569        .flatten()
570        .filter_map(|id| working.enrolments.get(id))
571        .filter(|enrolment| enrolment.course_code == course_code)
572        .cloned()
573        .collect()
574}
575
576fn enrolment_dto(
577    course_unit: &MockCourseUnit,
578    enrolment: &MockEnrolment,
579    realisation: &MockRealisation,
580) -> wire::SuotarEnrolment {
581    wire::SuotarEnrolment {
582        id: enrolment.id.clone(),
583        state: serde_plain(&enrolment.state),
584        kind: realisation.kind.as_str().to_string(),
585        course_unit_id: course_unit.course_unit_id.clone(),
586        assessment_item_id: realisation.assessment_item_id.clone(),
587        course_unit_realisation_id: realisation.id.clone(),
588        course_unit_realisation_name: realisation.name.clone(),
589        activity_period: realisation.activity_period.clone(),
590        grade_scale_id: realisation.grade_scale_id.clone(),
591        credits: realisation.credits.clone(),
592        study_right_id: enrolment.study_right_id.clone(),
593        study_right_validity_period: enrolment.study_right_validity_period.clone(),
594        enrolment_date_time: enrolment.enrolment_date_time,
595    }
596}
597
598/// Renders a wire-shaped enum through its serde spelling rather than duplicating the strings.
599fn serde_plain<T: Serialize>(value: &T) -> String {
600    serde_json::to_value(value)
601        .ok()
602        .and_then(|value| value.as_str().map(str::to_string))
603        .unwrap_or_default()
604}
605
606#[cfg(test)]
607mod tests {
608    use chrono::Duration;
609
610    use super::super::ids;
611    use super::super::world::{
612        CreditRange, DatePeriod, LocalizedName, MockCourseUnit, MockPerson, MockRealisation,
613        PersonBehaviour, RealisationKind, WorldDefaults,
614    };
615    use super::*;
616
617    const STUDENT_NUMBER: &str = "900000101";
618    const COURSE_CODE: &str = "CRS-101";
619
620    fn world(ripeness: Ripeness) -> WorkingSet {
621        let now = Utc::now();
622        let period = DatePeriod {
623            start_date: (now - Duration::days(30)).date_naive(),
624            end_date: (now + Duration::days(30)).date_naive(),
625        };
626        let realisation = MockRealisation {
627            id: ids::realisation_id(COURSE_CODE, RealisationKind::Degree),
628            name: LocalizedName {
629                fi: COURSE_CODE.to_string(),
630                sv: COURSE_CODE.to_string(),
631                en: COURSE_CODE.to_string(),
632            },
633            assessment_item_id: ids::assessment_item_id(COURSE_CODE, RealisationKind::Degree),
634            kind: RealisationKind::Degree,
635            activity_period: period.clone(),
636            grade_scale_id: "sis-hyl-hyv".to_string(),
637            credits: CreditRange { min: 5.0, max: 5.0 },
638            acceptor_person_id: Some("hy-hlo-acceptor".to_string()),
639            open_university_product_id: None,
640        };
641        let enrolment = MockEnrolment {
642            id: ids::enrolment_id(STUDENT_NUMBER, RealisationKind::Degree),
643            student_number: STUDENT_NUMBER.to_string(),
644            course_code: COURSE_CODE.to_string(),
645            realisation_id: realisation.id.clone(),
646            state: EnrolmentState::Enrolled,
647            study_right_id: ids::study_right_id(STUDENT_NUMBER, RealisationKind::Degree),
648            study_right_validity_period: period.clone(),
649            enrolment_date_time: now,
650        };
651        WorkingSet {
652            defaults: WorldDefaults::default(),
653            persons: [(
654                STUDENT_NUMBER.to_string(),
655                MockPerson {
656                    student_number: STUDENT_NUMBER.to_string(),
657                    person_id: ids::person_id(STUDENT_NUMBER),
658                    first_names: "Zzyzx".to_string(),
659                    last_name: "Happypath".to_string(),
660                    primary_email: "zzyzx.happypath@helsinki.example".to_string(),
661                    secondary_email: None,
662                    behaviour: PersonBehaviour {
663                        ripeness: Some(ripeness),
664                        duplicate_detection: None,
665                    },
666                    owner_user_email: None,
667                },
668            )]
669            .into(),
670            course_units: [(
671                COURSE_CODE.to_string(),
672                MockCourseUnit {
673                    course_code: COURSE_CODE.to_string(),
674                    course_unit_id: ids::course_unit_id(COURSE_CODE),
675                    name: LocalizedName {
676                        fi: COURSE_CODE.to_string(),
677                        sv: COURSE_CODE.to_string(),
678                        en: COURSE_CODE.to_string(),
679                    },
680                    realisations: vec![realisation],
681                    behaviour: Default::default(),
682                    owner_course_slug: None,
683                },
684            )]
685            .into(),
686            enrolments: [(enrolment.id.clone(), enrolment)].into(),
687            ..Default::default()
688        }
689    }
690
691    fn import(working: &mut WorkingSet) -> SuotarResponseItem<ImportAttainmentResult> {
692        let item = wire::ImportAttainmentRequestItem {
693            request_item_id: "cr-1".to_string(),
694            student_number: STUDENT_NUMBER.to_string(),
695            course_code: COURSE_CODE.to_string(),
696            enrolment_id: ids::enrolment_id(STUDENT_NUMBER, RealisationKind::Degree),
697            attainment_date: Utc::now().date_naive(),
698            attainment_language: "en".to_string(),
699            grade_scale_id: "sis-hyl-hyv".to_string(),
700            grade_id: "1".to_string(),
701            credits: 5.0,
702        };
703        import_item(&item, working, Utc::now())
704    }
705
706    fn verify(
707        working: &mut WorkingSet,
708        submitted_attainment_id: &str,
709    ) -> SuotarResponseItem<wire::VerifyAttainmentResult> {
710        let item = wire::VerifyAttainmentRequestItem {
711            request_item_id: "vf-1".to_string(),
712            submitted_attainment_id: submitted_attainment_id.to_string(),
713        };
714        verify_item(&item, working, Utc::now())
715    }
716
717    fn submitted_id(item: &SuotarResponseItem<ImportAttainmentResult>) -> String {
718        item.result
719            .as_ref()
720            .and_then(|result| result.submitted_attainment_id.clone())
721            .expect("a sent import answers with the submitted attainment id")
722    }
723
724    /// The write is queued before the response is shaped, which is what makes "timed out, but it
725    /// landed" different from "timed out, nothing landed".
726    #[test]
727    fn an_import_queues_its_submission_before_any_response_shaping() {
728        let mut working = world(Ripeness::Manual);
729        let response = import(&mut working);
730        assert_eq!(response.code, "sent");
731        let id = submitted_id(&response);
732        assert!(working.submissions.contains_key(&id));
733        assert!(
734            working
735                .writes
736                .contains(&WorldWrite::UpsertSubmission(id.clone()))
737        );
738        assert!(working.writes.iter().any(|write| matches!(
739            write,
740            WorldWrite::IndexSubmission { id: indexed, .. } if indexed == &id
741        )));
742    }
743
744    /// The verify count is taken before ripeness is evaluated, so `calls: 1` answers the first poll
745    /// with `notRegistered` and the second with `registered`.
746    #[test]
747    fn auto_ripening_counts_the_call_it_is_answering() {
748        let mut working = world(Ripeness::AutoAfterVerifyCalls { calls: 1 });
749        let id = submitted_id(&import(&mut working));
750        assert_eq!(verify(&mut working, &id).code, "notRegistered");
751        let second = verify(&mut working, &id);
752        assert_eq!(second.code, "registered");
753        let third = verify(&mut working, &id);
754        assert_eq!(second.result, third.result);
755    }
756
757    /// Nothing moves without an explicit transition, whatever a concurrent spec's verify sweep does.
758    #[test]
759    fn a_manual_submission_never_ripens_on_its_own() {
760        let mut working = world(Ripeness::Manual);
761        let id = submitted_id(&import(&mut working));
762        for _ in 0..5 {
763            assert_eq!(verify(&mut working, &id).code, "notRegistered");
764        }
765        register(&mut working, &id, Utc::now());
766        assert_eq!(verify(&mut working, &id).code, "registered");
767    }
768
769    #[test]
770    fn an_at_import_submission_is_registered_by_the_import_that_created_it() {
771        let mut working = world(Ripeness::AtImport);
772        let response = import(&mut working);
773        assert_eq!(response.code, "registered");
774    }
775}