Skip to main content

headless_lms_credit_registration/use_cases/import/
decide.rs

1//! What an import answer does to its row.
2
3use headless_lms_models::credit_registrations::{
4    AdminAttention, CreditRegistration, CreditRegistrationState,
5};
6use headless_lms_models::library::credit_registration::outcomes::{
7    Outcome, RowFacts, import_success_outcome, submission_uncertain, submit_error_outcome,
8    unanswered_item_outcome,
9};
10use headless_lms_models::library::credit_registration::study_registry::RegistryAttainment;
11
12use crate::registry::{AttainmentSubmission, BatchRequest, HeldCredit, ImportAnswer};
13use crate::workflow::Decision;
14
15/// What the study registry's answer for one submitted row does to it. Anything the answer disclosed
16/// about the attainment is written before the move.
17pub(super) fn decide_import_answer<'a>(
18    row: &CreditRegistration,
19    answer: Option<&'a ImportAnswer>,
20    facts: &RowFacts,
21) -> Decision<'a> {
22    // Sent and unanswered: verified from here, never re-sent.
23    let Some(answer) = answer else {
24        return Decision::new(unanswered_item_outcome(
25            AttainmentSubmission::OPERATION,
26            row.state,
27            facts,
28        ))
29        .with_message(
30            "Sisu did not answer for this item, so we do not know yet whether the credits were \
31             registered.",
32        );
33    };
34    match answer {
35        ImportAnswer::Submitted {
36            submission,
37            is_repeat_in_batch,
38        } => {
39            let Some(submission) = submission else {
40                // Accepted with nothing to verify by; recovery is a lookup among the student's
41                // existing attainments, never a second import.
42                return Decision::new(submission_uncertain())
43                    .with_message("The submission was accepted without an id to verify it by.");
44            };
45            let outcome =
46                import_success_outcome(CreditRegistrationState::AwaitingVerification, facts);
47            if !*is_repeat_in_batch {
48                return Decision::new(outcome).with_submitted_attainment(Some(submission));
49            }
50            error!(
51                credit_registration_id = %row.id,
52                "Suotar answered duplicateRequestItem; a batch carried the same completion twice"
53            );
54            Decision::new(Outcome {
55                needs_admin_attention: Some(AdminAttention::Raise),
56                ..outcome
57            })
58            .with_submitted_attainment(Some(submission))
59            .with_message(
60                "Suotar found this completion twice in one batch and submitted only the first; the \
61                 batch should never have held both.",
62            )
63        }
64        ImportAnswer::Settled { held, attainment } => {
65            let attainment = attainment.as_ref();
66            Decision::new(import_success_outcome(held.state(), facts))
67                .with_sisu_attainment(attainment)
68                .with_message(settled_message(*held, attainment))
69        }
70        // A `sisuTimeout` still names the submission it may have made, which turns its recovery
71        // into plain verification instead of a hunt through the student's existing attainments.
72        ImportAnswer::Refused {
73            code,
74            submission,
75            error_message,
76        } => {
77            let outcome = submit_error_outcome(AttainmentSubmission::OPERATION, *code, facts);
78            let submission = submission
79                .as_ref()
80                .filter(|_| outcome.to_state == CreditRegistrationState::SubmissionUncertain);
81            Decision::new(outcome)
82                .with_submitted_attainment(submission)
83                .with_row_error(error_message.as_deref())
84        }
85        ImportAnswer::UnknownSuccessCode => Decision::new(submission_uncertain()).with_message(
86            "Sisu answered with a success code we do not know, so we do not know yet whether the \
87             credits were registered.",
88        ),
89    }
90}
91
92/// The timeline line for an answer that settled the row. `not_improved` names the grade the registry
93/// held, because "already equal or better" without it reads as a bug to whoever raised the grade.
94fn settled_message(held: HeldCredit, attainment: Option<&RegistryAttainment>) -> String {
95    match held {
96        HeldCredit::Duplicate => "Sisu already had these credits.".to_string(),
97        HeldCredit::NotImproved => match attainment.and_then(RegistryAttainment::display_grade) {
98            Some(grade) => format!("Sisu already has an equal or better grade: {grade}."),
99            None => "Sisu already has an equal or better grade.".to_string(),
100        },
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
107    use headless_lms_models::credit_registrations::CreditRegistrationState as State;
108
109    use super::*;
110    use crate::registry::{AttainmentId, SubmittedAttainmentRef};
111    use crate::test_fixtures::{attainment, date, now, registration};
112
113    fn submission() -> SubmittedAttainmentRef {
114        SubmittedAttainmentRef {
115            id: AttainmentId::new("submitted-1"),
116            attainment_type: Some("AssessmentItemAttainment".to_string()),
117        }
118    }
119
120    fn decide(answer: Option<&ImportAnswer>) -> Decision<'_> {
121        let row = registration(State::CheckingEnrolment);
122        decide_import_answer(&row, answer, &RowFacts::of(&row, now()))
123    }
124
125    fn submitted_id<'a>(decision: &Decision<'a>) -> Option<&'a str> {
126        decision
127            .pre_transition
128            .submitted_attainment
129            .map(|submission| submission.id.as_str())
130    }
131
132    #[test]
133    fn an_unanswered_item_is_uncertain_and_never_resent() {
134        let decision = decide(None);
135        assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
136        assert!(decision.message.as_deref().is_some());
137        assert!(submitted_id(&decision).is_none());
138    }
139
140    #[test]
141    fn an_accepted_submission_awaits_verification_by_its_id() {
142        let answer = ImportAnswer::Submitted {
143            submission: Some(submission()),
144            is_repeat_in_batch: false,
145        };
146        let decision = decide(Some(&answer));
147        assert_eq!(decision.outcome.to_state, State::AwaitingVerification);
148        assert_eq!(decision.outcome.needs_admin_attention, None);
149        assert_eq!(submitted_id(&decision), Some("submitted-1"));
150        assert!(decision.message.as_deref().is_none());
151        assert!(decision.atomic.payload.as_ref().is_none());
152    }
153
154    #[test]
155    fn a_repeat_in_one_batch_is_verified_and_flagged_for_an_admin() {
156        let answer = ImportAnswer::Submitted {
157            submission: Some(submission()),
158            is_repeat_in_batch: true,
159        };
160        let decision = decide(Some(&answer));
161        assert_eq!(decision.outcome.to_state, State::AwaitingVerification);
162        assert_eq!(
163            decision.outcome.needs_admin_attention,
164            Some(AdminAttention::Raise)
165        );
166        assert_eq!(submitted_id(&decision), Some("submitted-1"));
167        assert!(decision.message.as_deref().is_some());
168    }
169
170    #[test]
171    fn an_accepted_submission_without_an_id_is_uncertain() {
172        for is_repeat_in_batch in [false, true] {
173            let answer = ImportAnswer::Submitted {
174                submission: None,
175                is_repeat_in_batch,
176            };
177            let decision = decide(Some(&answer));
178            assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
179            assert!(submitted_id(&decision).is_none());
180            assert!(decision.message.as_deref().is_some());
181        }
182    }
183
184    #[test]
185    fn a_duplicate_answer_settles_as_duplicate_with_the_held_attainment() {
186        let held = attainment("sis-0-5", "4", date(2026, 8, 1));
187        let answer = ImportAnswer::Settled {
188            held: HeldCredit::Duplicate,
189            attainment: Some(held.clone()),
190        };
191        let decision = decide(Some(&answer));
192        assert_eq!(decision.outcome.to_state, State::Duplicate);
193        assert_eq!(decision.pre_transition.sisu_attainment, Some(&held));
194        assert_eq!(
195            decision.message.as_deref(),
196            Some("Sisu already had these credits.")
197        );
198    }
199
200    #[test]
201    fn a_not_improved_answer_names_the_grade_and_scale_sisu_holds() {
202        let answer = ImportAnswer::Settled {
203            held: HeldCredit::NotImproved,
204            attainment: Some(attainment("sis-0-5", "5", date(2026, 8, 1))),
205        };
206        let decision = decide(Some(&answer));
207        assert_eq!(decision.outcome.to_state, State::NotImproved);
208        assert_eq!(
209            decision.message.as_deref(),
210            Some("Sisu already has an equal or better grade: 5 on sis-0-5.")
211        );
212    }
213
214    #[test]
215    fn a_not_improved_answer_without_the_held_attainment_still_says_why() {
216        let answer = ImportAnswer::Settled {
217            held: HeldCredit::NotImproved,
218            attainment: None,
219        };
220        let decision = decide(Some(&answer));
221        assert_eq!(decision.outcome.to_state, State::NotImproved);
222        assert!(decision.pre_transition.sisu_attainment.is_none());
223        assert_eq!(
224            decision.message.as_deref(),
225            Some("Sisu already has an equal or better grade.")
226        );
227    }
228
229    #[test]
230    fn a_sisu_timeout_keeps_the_submission_it_names_for_verification() {
231        let answer = ImportAnswer::Refused {
232            code: Code::SisuTimeout,
233            submission: Some(submission()),
234            error_message: Some("Sisu timed out".to_string()),
235        };
236        let decision = decide(Some(&answer));
237        assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
238        assert_eq!(submitted_id(&decision), Some("submitted-1"));
239        assert_eq!(decision.row_error, Some("Sisu timed out"));
240    }
241
242    #[test]
243    fn a_refusal_that_proves_nothing_landed_drops_the_submission_it_names() {
244        let answer = ImportAnswer::Refused {
245            code: Code::GradeScaleMismatch,
246            submission: Some(submission()),
247            error_message: Some("wrong scale".to_string()),
248        };
249        let decision = decide(Some(&answer));
250        assert_eq!(decision.outcome.to_state, State::FailedPermanent);
251        assert_eq!(decision.outcome.error_code, Some(Code::GradeScaleMismatch));
252        assert!(submitted_id(&decision).is_none());
253        assert_eq!(decision.row_error, Some("wrong scale"));
254    }
255
256    #[test]
257    fn an_unknown_success_code_is_uncertain() {
258        let decision = decide(Some(&ImportAnswer::UnknownSuccessCode));
259        assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
260        assert!(decision.message.as_deref().is_some());
261    }
262}