Skip to main content

headless_lms_credit_registration/use_cases/verify/
decide.rs

1//! What a verify answer does to its row.
2
3use 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
19/// What a poll's answer comes to, where that needs no more than the answer.
20pub(super) enum PollAnswer<'a> {
21    Decided(Box<Decision<'a>>),
22    /// Only the assessment item attainment is there yet; the outcome depends on when a poll first
23    /// saw that.
24    PartiallyRegistered,
25    /// Suotar has no trace of the submission; the outcome depends on how often that happened.
26    NotRegistered,
27}
28
29/// Only a course unit attainment registers the row, and only `notRegistered` sends it back towards
30/// import; anything else keeps it polling.
31pub(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            // Confirmed, so whatever an operator was asked to look at is settled.
42            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        // `submissionPending`: polled on as usual, since the attainment usually shows up long
48        // before `retryAfter`, which only bounds when a resubmission becomes safe.
49        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
66/// Keeps polling in place; past the verify window the expiry is the news, so it replaces
67/// `message`.
68fn 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
86/// A poll that found only the assessment item attainment, which a poll first saw at
87/// `partially_registered_at`.
88pub(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
98/// A poll that found no trace of the submission.
99pub(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
104/// Settles an uncertain row as `duplicate` if the lookup found the attainment it would have created,
105/// and otherwise leaves it uncertain.
106pub(super) fn decide_recovery<'a>(
107    row: &CreditRegistration,
108    facts: &RowFacts,
109    answer: Option<&'a EnrolmentAnswer>,
110) -> Decision<'a> {
111    // An enrolment error still lists the attainments, and the enrolment may be gone by now.
112    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(&registration(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(&registered));
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}