headless_lms_credit_registration/use_cases/verify/
decide.rs1use chrono::{DateTime, Utc};
4use headless_lms_models::credit_registrations::{
5 AdminAttention, CreditRegistration, CreditRegistrationState,
6};
7use headless_lms_models::library::credit_registration::backoff::{
8 VERIFY_MAX_AGE, verify_window_expired,
9};
10use headless_lms_models::library::credit_registration::enrolment_selection::attainment_matching_submission;
11use headless_lms_models::library::credit_registration::outcomes::{
12 Outcome, RowFacts, uncertain_recheck_outcome, verify_error_outcome,
13 verify_inconclusive_outcome, verify_not_registered_outcome, verify_partial_outcome,
14};
15
16use crate::registry::{EnrolmentAnswer, VerificationAnswer, VerificationReading};
17use crate::workflow::Decision;
18
19pub(super) enum PollAnswer<'a> {
21 Decided(Box<Decision<'a>>),
22 PartiallyRegistered,
25 NotRegistered,
27}
28
29pub(super) fn decide_poll<'a>(
32 state: CreditRegistrationState,
33 answer: Option<&'a VerificationAnswer>,
34 facts: &RowFacts,
35) -> PollAnswer<'a> {
36 let Some(answer) = answer else {
37 return PollAnswer::Decided(Box::new(still_polling_decision(state, facts, None)));
38 };
39 let decision = match &answer.reading {
40 VerificationReading::Registered { attainment } => Decision::new(Outcome {
41 needs_admin_attention: Some(AdminAttention::Clear),
43 ..Outcome::to(CreditRegistrationState::Registered)
44 })
45 .with_sisu_attainment(Some(attainment)),
46 VerificationReading::PartiallyRegistered => return PollAnswer::PartiallyRegistered,
47 VerificationReading::Pending {
50 resubmit_not_before,
51 } => still_polling_decision(
52 state,
53 facts,
54 Some("Sisu is still processing the submission."),
55 )
56 .with_resubmit_not_before(*resubmit_not_before),
57 VerificationReading::NotRegistered => return PollAnswer::NotRegistered,
58 VerificationReading::Failed { code } => {
59 Decision::new(verify_error_outcome(state, *code, facts))
60 }
61 VerificationReading::Inconclusive => still_polling_decision(state, facts, None),
62 };
63 PollAnswer::Decided(Box::new(decision))
64}
65
66fn still_polling_decision<'a>(
69 state: CreditRegistrationState,
70 facts: &RowFacts,
71 message: Option<&'static str>,
72) -> Decision<'a> {
73 let decision = Decision::new(verify_inconclusive_outcome(state, facts));
74 if verify_window_expired(facts.submitted_at, facts.now) {
75 decision.with_message(format!(
76 "Not confirmed within {} days of sending; now checked once a day.",
77 VERIFY_MAX_AGE.num_days()
78 ))
79 } else if let Some(message) = message {
80 decision.with_message(message)
81 } else {
82 decision
83 }
84}
85
86pub(super) fn partially_registered_decision(
89 facts: &RowFacts,
90 partially_registered_at: DateTime<Utc>,
91) -> Decision<'static> {
92 Decision::new(verify_partial_outcome(facts, partially_registered_at)).with_message(
93 "Sisu has the assessment item attainment; waiting for the course unit attainment before \
94 counting it registered.",
95 )
96}
97
98pub(super) fn not_registered_decision(facts: &RowFacts, reimport_count: i32) -> Decision<'static> {
100 Decision::new(verify_not_registered_outcome(facts, reimport_count))
101 .with_message("Sisu has no trace of the submission, so it will be sent again.")
102}
103
104pub(super) fn decide_recovery<'a>(
107 row: &CreditRegistration,
108 facts: &RowFacts,
109 answer: Option<&'a EnrolmentAnswer>,
110) -> Decision<'a> {
111 let found = answer
113 .zip(row.attained_at.map(|instant| instant.date_naive()))
114 .zip(row.frozen_grade())
115 .and_then(|((answer, attainment_date), grade)| {
116 attainment_matching_submission(
117 &answer.existing_attainments,
118 attainment_date,
119 row.submitted_at,
120 &grade,
121 )
122 });
123 let Some(attainment) = found else {
124 return Decision::new(uncertain_recheck_outcome(facts)).with_message(
125 "No matching attainment yet, so whether the submission landed is still unknown.",
126 );
127 };
128 Decision::new(Outcome {
129 needs_admin_attention: Some(AdminAttention::Clear),
130 ..Outcome::to(CreditRegistrationState::Duplicate)
131 })
132 .with_sisu_attainment(Some(attainment))
133 .with_message(
134 "The credits this submission would have created are in Sisu, so it was registered after \
135 all.",
136 )
137}
138
139#[cfg(test)]
140mod tests {
141 use chrono::TimeDelta;
142 use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
143 use headless_lms_models::credit_registrations::CreditRegistrationState as State;
144 use headless_lms_models::library::credit_registration::backoff::{
145 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER,
146 };
147 use headless_lms_models::library::credit_registration::outcomes::NextAttempt;
148 use headless_lms_models::library::credit_registration::study_registry::RegistryAttainment;
149
150 use super::*;
151 use crate::registry::EnrolmentReading;
152 use crate::test_fixtures::{attainment, date, now, registration};
153
154 fn facts() -> RowFacts {
155 RowFacts::of(®istration(State::AwaitingVerification), now())
156 }
157
158 fn answer(reading: VerificationReading) -> VerificationAnswer {
159 VerificationAnswer {
160 reading,
161 error_message: Some("item error".to_string()),
162 }
163 }
164
165 fn decided<'a>(state: State, answer: Option<&'a VerificationAnswer>) -> Decision<'a> {
166 match decide_poll(state, answer, &facts()) {
167 PollAnswer::Decided(decision) => *decision,
168 PollAnswer::PartiallyRegistered => panic!("partially registered"),
169 PollAnswer::NotRegistered => panic!("not registered"),
170 }
171 }
172
173 #[test]
174 fn a_course_unit_attainment_registers_the_row_and_settles_admin_attention() {
175 let registered = attainment("sis-0-5", "4", date(2026, 8, 1));
176 let answer = answer(VerificationReading::Registered {
177 attainment: registered.clone(),
178 });
179 let decision = decided(State::SubmissionUncertain, Some(&answer));
180 assert_eq!(decision.outcome.to_state, State::Registered);
181 assert_eq!(
182 decision.outcome.needs_admin_attention,
183 Some(AdminAttention::Clear)
184 );
185 assert_eq!(decision.pre_transition.sisu_attainment, Some(®istered));
186 }
187
188 #[test]
189 fn an_unanswered_poll_keeps_polling_in_place() {
190 for state in [
191 State::AwaitingVerification,
192 State::PartiallyRegistered,
193 State::SubmissionUncertain,
194 ] {
195 let decision = decided(state, None);
196 assert_eq!(decision.outcome.to_state, state);
197 assert!(!decision.outcome.is_failure());
198 }
199 }
200
201 #[test]
202 fn a_pending_submission_keeps_polling_and_records_when_resending_is_safe() {
203 let resubmit_not_before = now() + TimeDelta::hours(3);
204 let answer = answer(VerificationReading::Pending {
205 resubmit_not_before: Some(resubmit_not_before),
206 });
207 let decision = decided(State::AwaitingVerification, Some(&answer));
208 assert_eq!(decision.outcome.to_state, State::AwaitingVerification);
209 assert_eq!(
210 decision.pre_transition.resubmit_not_before,
211 Some(resubmit_not_before)
212 );
213 assert!(matches!(decision.outcome.next, NextAttempt::After(_)));
214 }
215
216 #[test]
217 fn only_misregistered_moves_a_failed_poll_out_of_verification() {
218 for code in Code::ALL {
219 let answer = answer(VerificationReading::Failed { code });
220 let decision = decided(State::AwaitingVerification, Some(&answer));
221 let expected = if code == Code::Misregistered {
222 State::Misregistered
223 } else {
224 State::AwaitingVerification
225 };
226 assert_eq!(decision.outcome.to_state, expected, "{code:?}");
227 }
228 }
229
230 #[test]
231 fn an_inconclusive_answer_keeps_polling() {
232 let answer = answer(VerificationReading::Inconclusive);
233 let decision = decided(State::SubmissionUncertain, Some(&answer));
234 assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
235 assert!(!decision.outcome.is_failure());
236 }
237
238 #[test]
239 fn partial_and_missing_registrations_are_left_to_the_row_history() {
240 let partial = answer(VerificationReading::PartiallyRegistered);
241 assert!(matches!(
242 decide_poll(State::AwaitingVerification, Some(&partial), &facts()),
243 PollAnswer::PartiallyRegistered
244 ));
245 let missing = answer(VerificationReading::NotRegistered);
246 assert!(matches!(
247 decide_poll(State::AwaitingVerification, Some(&missing), &facts()),
248 PollAnswer::NotRegistered
249 ));
250 }
251
252 #[test]
253 fn a_partial_registration_waits_for_the_course_unit_attainment() {
254 let decision = partially_registered_decision(&facts(), now());
255 assert_eq!(decision.outcome.to_state, State::PartiallyRegistered);
256 assert_eq!(decision.outcome.needs_admin_attention, None);
257 }
258
259 #[test]
260 fn a_long_partial_registration_asks_for_an_admin() {
261 let decision =
262 partially_registered_decision(&facts(), now() - PARTIAL_REGISTRATION_ADMIN_AFTER);
263 assert_eq!(decision.outcome.to_state, State::PartiallyRegistered);
264 assert_eq!(
265 decision.outcome.needs_admin_attention,
266 Some(AdminAttention::Raise)
267 );
268 }
269
270 #[test]
271 fn not_registered_sends_the_row_back_to_be_imported_again() {
272 let decision = not_registered_decision(&facts(), 1);
273 assert_eq!(decision.outcome.to_state, State::FailedRetryable);
274 assert_eq!(decision.outcome.error_code, Some(Code::NotRegistered));
275 assert!(decision.outcome.increment_submit_retry_count);
276 assert!(decision.message.as_deref().is_some());
277 }
278
279 #[test]
280 fn repeated_not_registered_asks_for_an_admin() {
281 let decision = not_registered_decision(&facts(), NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD);
282 assert_eq!(
283 decision.outcome.needs_admin_attention,
284 Some(AdminAttention::Raise)
285 );
286 }
287
288 fn uncertain_row() -> CreditRegistration {
289 CreditRegistration {
290 attained_at: Some("2026-08-01T09:00:00Z".parse().expect("valid instant")),
291 submitted_at: Some(now() - TimeDelta::hours(1)),
292 grade_scale_id: Some("sis-0-5".to_string()),
293 grade_id: Some("4".to_string()),
294 ..registration(State::SubmissionUncertain)
295 }
296 }
297
298 fn enrolment_answer(existing: Vec<RegistryAttainment>) -> EnrolmentAnswer {
299 EnrolmentAnswer {
300 reading: EnrolmentReading::Listed,
301 enrolments: Vec::new(),
302 existing_attainments: existing,
303 }
304 }
305
306 #[test]
307 fn recovery_finding_the_attainment_settles_the_row_as_duplicate() {
308 let row = uncertain_row();
309 let found = attainment("sis-0-5", "4", date(2026, 8, 1));
310 let answer = enrolment_answer(vec![found.clone()]);
311 let decision = decide_recovery(&row, &RowFacts::of(&row, now()), Some(&answer));
312 assert_eq!(decision.outcome.to_state, State::Duplicate);
313 assert_eq!(
314 decision.outcome.needs_admin_attention,
315 Some(AdminAttention::Clear)
316 );
317 assert_eq!(decision.pre_transition.sisu_attainment, Some(&found));
318 }
319
320 #[test]
321 fn recovery_ignores_an_attainment_with_another_grade() {
322 let row = uncertain_row();
323 let answer = enrolment_answer(vec![attainment("sis-0-5", "3", date(2026, 8, 1))]);
324 let decision = decide_recovery(&row, &RowFacts::of(&row, now()), Some(&answer));
325 assert_eq!(decision.outcome.to_state, State::SubmissionUncertain);
326 assert!(decision.pre_transition.sisu_attainment.is_none());
327 assert!(decision.message.as_deref().is_some());
328 }
329
330 #[test]
331 fn recovery_without_an_answer_or_an_attainment_date_stays_uncertain() {
332 let row = uncertain_row();
333 let facts = RowFacts::of(&row, now());
334 assert_eq!(
335 decide_recovery(&row, &facts, None).outcome.to_state,
336 State::SubmissionUncertain
337 );
338 let undated = CreditRegistration {
339 attained_at: None,
340 ..uncertain_row()
341 };
342 let answer = enrolment_answer(vec![attainment("sis-0-5", "4", date(2026, 8, 1))]);
343 assert_eq!(
344 decide_recovery(&undated, &facts, Some(&answer))
345 .outcome
346 .to_state,
347 State::SubmissionUncertain
348 );
349 }
350}