headless_lms_credit_registration/workflow/
refusal.rs1use 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
12pub(crate) enum RefusalPolicy<Extra> {
15 RequestLevel,
19 KeepWaiting {
22 outcome: fn(&Claimed<Extra>, DateTime<Utc>) -> Outcome,
23 message: &'static str,
24 },
25}
26
27impl<Extra> RefusalPolicy<Extra> {
28 pub(crate) fn may_split(&self) -> bool {
30 matches!(self, Self::RequestLevel)
31 }
32
33 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}