Skip to main content

headless_lms_credit_registration/workflow/
refusal.rs

1//! What a row of a request the study registry refused as a whole is written as.
2
3use chrono::{DateTime, Utc};
4use headless_lms_models::library::credit_registration::outcomes::{
5    Outcome, isolated_malformed_request_outcome, request_level_outcome,
6};
7
8use super::claim::Claimed;
9use super::decision::Decision;
10use crate::registry::{RefusedFor, RegistryError, RegistryOperation};
11
12/// What a flow's rows get when the whole request was refused. One per flow, so whether its batches
13/// may be split is known before any row is looked at.
14pub(crate) enum RefusalPolicy<Extra> {
15    /// The shared request-level outcome. A malformed-request refusal proves nothing was acted on,
16    /// and may be down to one row alone, so the batch is split in halves until the rows it keeps
17    /// refusing are alone in theirs.
18    RequestLevel,
19    /// Whatever the refusal, the row keeps waiting where it is, as `outcome` says for it: a failure
20    /// to ask proves nothing about a submission that may have landed. Never split.
21    KeepWaiting {
22        outcome: fn(&Claimed<Extra>, DateTime<Utc>) -> Outcome,
23        message: &'static str,
24    },
25}
26
27impl<Extra> RefusalPolicy<Extra> {
28    /// Whether a malformed-request refusal of several rows may be split to find the one to blame.
29    pub(crate) fn may_split(&self) -> bool {
30        matches!(self, Self::RequestLevel)
31    }
32
33    /// The decision for `row` of a request of `operation` the registry refused. A row refused alone
34    /// needs a human, since resending cannot fix it.
35    pub(crate) fn decision<'a>(
36        &self,
37        row: &Claimed<Extra>,
38        operation: RegistryOperation,
39        error: &'a RegistryError,
40        refused_for: RefusedFor,
41        now: DateTime<Utc>,
42    ) -> Decision<'a> {
43        let (outcome, message) = match (self, refused_for) {
44            (Self::RequestLevel, RefusedFor::RowAlone) => (
45                isolated_malformed_request_outcome(),
46                "Sisu did not accept this row even when it was sent alone.",
47            ),
48            (Self::RequestLevel, RefusedFor::WholeBatch) => (
49                request_level_outcome(operation, error.kind, &row.claim.facts(now)),
50                "Sisu did not accept the whole request.",
51            ),
52            (Self::KeepWaiting { outcome, message }, _) => (outcome(row, now), *message),
53        };
54        Decision::new(outcome)
55            .with_message(message)
56            .with_row_error(Some(&error.message))
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use chrono::TimeDelta;
63    use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
64    use headless_lms_models::credit_registrations::CreditRegistrationState as State;
65    use headless_lms_models::credit_registrations::{AdminAttention, CreditRegistration};
66    use headless_lms_models::library::credit_registration::study_registry::RegistryErrorKind as Kind;
67
68    use super::*;
69    use crate::test_fixtures::{now, registration};
70    use crate::workflow::ClaimedRegistration;
71
72    const KINDS: [Kind; 8] = [
73        Kind::AuthenticationFailure,
74        Kind::MalformedRequest,
75        Kind::RejectedRequest,
76        Kind::TemporarilyUnavailable,
77        Kind::ServerError,
78        Kind::NotDelivered,
79        Kind::NoAnswer,
80        Kind::ProtocolViolation,
81    ];
82
83    fn claim(state: State, in_flight: State) -> Claimed<()> {
84        Claimed {
85            claim: ClaimedRegistration::moved_to(registration(state), in_flight),
86            extra: (),
87        }
88    }
89
90    fn waiting_claim() -> Claimed<()> {
91        let row = CreditRegistration {
92            error_code: Some(Code::EnrolmentNotFound),
93            no_usable_enrolment_since: Some(now() - TimeDelta::days(3)),
94            ..registration(State::NoUsableEnrolment)
95        };
96        Claimed {
97            claim: ClaimedRegistration::left_in_place(row),
98            extra: (),
99        }
100    }
101
102    fn decide<'a>(
103        operation: RegistryOperation,
104        row: &Claimed<()>,
105        error: &'a RegistryError,
106        refused_for: RefusedFor,
107    ) -> Decision<'a> {
108        RefusalPolicy::RequestLevel.decision(row, operation, error, refused_for, now())
109    }
110
111    #[test]
112    fn a_refused_import_is_uncertain_exactly_when_it_may_have_been_acted_on() {
113        let claim = claim(State::CheckingEnrolment, State::Submitting);
114        for kind in KINDS {
115            let error = RegistryError::new(kind, "refused");
116            let decision = decide(
117                RegistryOperation::ImportAttainments,
118                &claim,
119                &error,
120                RefusedFor::WholeBatch,
121            );
122            let is_uncertain = decision.outcome.to_state == State::SubmissionUncertain;
123            assert_eq!(is_uncertain, kind.may_have_been_acted_on(), "{kind:?}");
124            if !is_uncertain {
125                assert_eq!(
126                    decision.outcome.to_state,
127                    State::FailedRetryable,
128                    "{kind:?}"
129                );
130            }
131            assert_eq!(decision.row_error, Some("refused"));
132            assert_eq!(
133                decision.message.as_deref(),
134                Some("Sisu did not accept the whole request.")
135            );
136        }
137    }
138
139    #[test]
140    fn a_row_refused_even_alone_needs_an_admin() {
141        let error = RegistryError::new(Kind::MalformedRequest, "bad row");
142        for operation in [
143            RegistryOperation::ImportAttainments,
144            RegistryOperation::ResolveEnrolments,
145            RegistryOperation::ResolvePersons,
146        ] {
147            let decision = decide(
148                operation,
149                &claim(State::ReadyToSubmit, State::ResolvingEnrolment),
150                &error,
151                RefusedFor::RowAlone,
152            );
153            assert_eq!(decision.outcome.to_state, State::FailedPermanent);
154            assert_eq!(decision.outcome.error_code, Some(Code::MalformedRequest));
155            assert_eq!(
156                decision.outcome.needs_admin_attention,
157                Some(AdminAttention::Raise)
158            );
159            assert_eq!(decision.row_error, Some("bad row"));
160            assert_eq!(
161                decision.message.as_deref(),
162                Some("Sisu did not accept this row even when it was sent alone.")
163            );
164        }
165    }
166
167    #[test]
168    fn a_lookup_refused_in_an_outage_keeps_a_waiting_row_waiting() {
169        let claim = waiting_claim();
170        for kind in KINDS {
171            let error = RegistryError::new(kind, "refused");
172            let outcome = decide(
173                RegistryOperation::ResolveEnrolments,
174                &claim,
175                &error,
176                RefusedFor::WholeBatch,
177            )
178            .outcome;
179            if kind.is_outage() {
180                assert_eq!(outcome.to_state, State::NoUsableEnrolment, "{kind:?}");
181                assert_eq!(outcome.error_code, Some(Code::EnrolmentNotFound));
182                assert!(outcome.keeps_enrolment_checked_at);
183            } else {
184                assert_eq!(outcome.to_state, State::FailedRetryable, "{kind:?}");
185            }
186        }
187    }
188
189    #[test]
190    fn a_refused_first_lookup_retries_with_the_request_level_code() {
191        let claim = claim(State::ReadyToSubmit, State::ResolvingEnrolment);
192        let error = RegistryError::new(Kind::NotDelivered, "refused");
193        let outcome = decide(
194            RegistryOperation::ResolveEnrolments,
195            &claim,
196            &error,
197            RefusedFor::WholeBatch,
198        )
199        .outcome;
200        assert_eq!(outcome.to_state, State::FailedRetryable);
201        assert_eq!(outcome.error_code, Some(Code::TransportError));
202        assert!(outcome.increment_submit_retry_count);
203    }
204
205    #[test]
206    fn a_row_that_keeps_waiting_takes_the_flows_own_outcome_and_the_refusal() {
207        let claim = claim(State::AwaitingVerification, State::AwaitingVerification);
208        let error = RegistryError::new(Kind::MalformedRequest, "refused");
209        for refused_for in [RefusedFor::WholeBatch, RefusedFor::RowAlone] {
210            let policy: RefusalPolicy<()> = RefusalPolicy::KeepWaiting {
211                outcome: |_, _| Outcome::to(State::AwaitingVerification),
212                message: "still waiting",
213            };
214            assert!(!policy.may_split());
215            let decision = policy.decision(
216                &claim,
217                RegistryOperation::VerifyAttainments,
218                &error,
219                refused_for,
220                now(),
221            );
222            assert_eq!(decision.outcome.to_state, State::AwaitingVerification);
223            assert_eq!(decision.message.as_deref(), Some("still waiting"));
224            assert_eq!(decision.row_error, Some("refused"));
225        }
226    }
227}