Skip to main content

headless_lms_credit_registration/use_cases/resolve_enrolments/persons/
decide.rs

1//! What a person lookup's answer does to its row.
2
3use headless_lms_models::credit_registrations::{
4    CreditRegistrationErrorCode, CreditRegistrationState,
5};
6use headless_lms_models::library::credit_registration::outcomes::{
7    NextAttempt, Outcome, RowFacts, submit_error_outcome, unanswered_item_outcome,
8};
9
10use crate::registry::{BatchRequest, PersonLookup};
11use crate::use_cases::resolve_enrolments::Lookup;
12use crate::workflow::{ClaimedRegistration, Decision};
13
14/// A person lookup Suotar left unanswered for the row.
15pub(super) fn unanswered_person_decision(
16    state: CreditRegistrationState,
17    facts: &RowFacts,
18) -> Decision<'static> {
19    Decision::new(unanswered_item_outcome(
20        PersonLookup::OPERATION,
21        state,
22        facts,
23    ))
24    .with_message("Sisu did not answer for this item.")
25}
26
27/// A person lookup Suotar answered with an error code, such as an unknown student number.
28pub(super) fn refused_person_decision(
29    code: CreditRegistrationErrorCode,
30    facts: &RowFacts,
31) -> Decision<'static> {
32    Decision::new(submit_error_outcome(PersonLookup::OPERATION, code, facts))
33}
34
35/// What filling a found person in on the row's link came to.
36pub(super) enum PersonFill {
37    Filled,
38    /// The link changed while its person was looked up.
39    LinkChanged,
40    /// Another account's link already holds the person; the clash is recorded.
41    HeldByAnotherLink,
42}
43
44impl PersonFill {
45    /// Where the found person sends the row. Another account's link to the same person wins, as it
46    /// does for a conflicting number: the registrar's link is dropped and the clash recorded for an
47    /// admin.
48    pub(super) fn decision(self, claim: &ClaimedRegistration) -> Decision<'static> {
49        match self {
50            Self::Filled => Decision::new(found_person_outcome(claim))
51                .with_message("Found the Sisu person the linked student number belongs to."),
52            Self::LinkChanged => Decision::new(Outcome::to(CreditRegistrationState::Pending))
53                .with_message(
54                    "The linked student number changed while its Sisu person was looked up.",
55                ),
56            Self::HeldByAnotherLink => Decision::new(Outcome {
57                drop_verified_student_number: true,
58                ..Outcome::to(CreditRegistrationState::Pending)
59            })
60            .with_message(
61                "Another account's link already holds the Sisu person this student number belongs \
62                 to.",
63            ),
64        }
65    }
66}
67
68/// Leaves the row due for the enrolment lookup: a first resolve back in `ready_to_submit`, a parked
69/// row where it was, with its error code and last check time, since its enrolment was not checked.
70fn found_person_outcome(claim: &ClaimedRegistration) -> Outcome {
71    let registration = claim.registration();
72    match Lookup::of(registration) {
73        Lookup::FirstResolve => Outcome::to(CreditRegistrationState::ReadyToSubmit),
74        Lookup::ParkedCheck => Outcome {
75            error_code: registration.error_code,
76            next: NextAttempt::At(registration.next_attempt_at),
77            keeps_enrolment_checked_at: true,
78            ..Outcome::to(CreditRegistrationState::NoUsableEnrolment)
79        },
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use chrono::TimeDelta;
86    use headless_lms_models::credit_registrations::CreditRegistration;
87    use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
88    use headless_lms_models::credit_registrations::CreditRegistrationState as State;
89
90    use super::*;
91    use crate::test_fixtures::{now, registration};
92
93    fn parked_row() -> CreditRegistration {
94        CreditRegistration {
95            error_code: Some(Code::EnrolmentNotFound),
96            no_usable_enrolment_since: Some(now() - TimeDelta::days(3)),
97            next_attempt_at: now() + TimeDelta::days(1),
98            ..registration(State::NoUsableEnrolment)
99        }
100    }
101
102    fn unanswered(row: &CreditRegistration) -> Outcome {
103        unanswered_person_decision(row.state, &RowFacts::of(row, now())).outcome
104    }
105
106    #[test]
107    fn an_unanswered_first_resolve_retries_later() {
108        let outcome = unanswered(&registration(State::ReadyToSubmit));
109        assert_eq!(outcome.to_state, State::FailedRetryable);
110        assert_eq!(outcome.error_code, Some(Code::UnexpectedResponse));
111    }
112
113    #[test]
114    fn an_unanswered_parked_check_keeps_the_row_waiting_with_its_code() {
115        let outcome = unanswered(&parked_row());
116        assert_eq!(outcome.to_state, State::NoUsableEnrolment);
117        assert_eq!(outcome.error_code, Some(Code::EnrolmentNotFound));
118        assert!(outcome.keeps_enrolment_checked_at);
119    }
120
121    #[test]
122    fn person_not_found_drops_the_linked_number() {
123        let row = registration(State::ReadyToSubmit);
124        let decision = refused_person_decision(Code::PersonNotFound, &RowFacts::of(&row, now()));
125        let outcome = decision.outcome;
126        assert_eq!(outcome.to_state, State::Pending);
127        assert!(outcome.drop_verified_student_number);
128    }
129
130    fn fill(row: CreditRegistration, fill: PersonFill) -> Decision<'static> {
131        fill.decision(&crate::use_cases::resolve_enrolments::claim_for_lookup(row))
132    }
133
134    #[test]
135    fn a_filled_first_resolve_goes_on_to_the_enrolment_lookup() {
136        let decision = fill(registration(State::ReadyToSubmit), PersonFill::Filled);
137        assert_eq!(decision.outcome.to_state, State::ReadyToSubmit);
138        assert!(decision.message.as_deref().is_some());
139    }
140
141    #[test]
142    fn a_filled_parked_check_stays_parked_with_its_code_schedule_and_check_time() {
143        let row = parked_row();
144        let decision = fill(row.clone(), PersonFill::Filled);
145        let outcome = decision.outcome;
146        assert_eq!(outcome.to_state, State::NoUsableEnrolment);
147        assert_eq!(outcome.error_code, Some(Code::EnrolmentNotFound));
148        assert_eq!(outcome.next, NextAttempt::At(row.next_attempt_at));
149        assert!(outcome.keeps_enrolment_checked_at);
150    }
151
152    #[test]
153    fn a_link_that_changed_meanwhile_goes_back_to_pending_and_keeps_its_number() {
154        let decision = fill(registration(State::ReadyToSubmit), PersonFill::LinkChanged);
155        assert_eq!(decision.outcome.to_state, State::Pending);
156        assert!(!decision.outcome.drop_verified_student_number);
157    }
158
159    #[test]
160    fn a_person_held_by_another_link_drops_this_link() {
161        for row in [registration(State::ReadyToSubmit), parked_row()] {
162            let decision = fill(row, PersonFill::HeldByAnotherLink);
163            assert_eq!(decision.outcome.to_state, State::Pending);
164            assert!(decision.outcome.drop_verified_student_number);
165        }
166    }
167}