headless_lms_credit_registration/use_cases/import/
decide.rs1use 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
15pub(super) fn decide_import_answer<'a>(
18 row: &CreditRegistration,
19 answer: Option<&'a ImportAnswer>,
20 facts: &RowFacts,
21) -> Decision<'a> {
22 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 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 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
92fn 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}