1use headless_lms_models::credit_registrations::{
5 CreditRegistrationErrorCode, CreditRegistrationState,
6};
7use headless_lms_models::library::credit_registration::classification::{
8 Retryability, is_waiting_error, retryability,
9};
10use headless_lms_utils::services::suotar::{SuotarEndpoint, SuotarItemStatus};
11
12#[derive(Debug, PartialEq, Eq, Clone, Copy)]
14pub(super) enum WireOutcome {
15 Settled(CreditRegistrationState),
17 Unsettled,
20 Failure(CreditRegistrationErrorCode),
21}
22
23pub(super) const DUPLICATE_REQUEST_ITEM_CODE: &str = "duplicateRequestItem";
26pub(super) const PERSON_NOT_FOUND_CODE: &str = "personNotFound";
28
29fn wire_outcome(code: &str) -> WireOutcome {
34 use CreditRegistrationErrorCode as Code;
35 use CreditRegistrationState as State;
36 match code {
37 "sent" => WireOutcome::Settled(State::AwaitingVerification),
38 DUPLICATE_REQUEST_ITEM_CODE => WireOutcome::Settled(State::AwaitingVerification),
41 "registered" => WireOutcome::Settled(State::Registered),
42 "duplicateAttainment" => WireOutcome::Settled(State::Duplicate),
43 "notImprovedAttainment" => WireOutcome::Settled(State::NotImproved),
44 "personFound" | "enrolmentFound" | "enrolmentsListed" | "courseAllowed"
45 | "submissionPending" => WireOutcome::Unsettled,
46 "notRegistered" => WireOutcome::Failure(Code::NotRegistered),
47 PERSON_NOT_FOUND_CODE => WireOutcome::Failure(Code::PersonNotFound),
48 "courseCodeNotFound" => WireOutcome::Failure(Code::CourseCodeNotFound),
49 "enrolmentNotFound" => WireOutcome::Failure(Code::EnrolmentNotFound),
50 "enrolmentNotAccepted" => WireOutcome::Failure(Code::EnrolmentNotAccepted),
51 "invalidGradeForGradeScale" => WireOutcome::Failure(Code::InvalidGradeForGradeScale),
52 "gradeScaleMismatch" => WireOutcome::Failure(Code::GradeScaleMismatch),
53 "courseNotAllowed" => WireOutcome::Failure(Code::CourseNotAllowed),
54 "invalidCredits" => WireOutcome::Failure(Code::InvalidCredits),
55 "studyRightNotValid" => WireOutcome::Failure(Code::StudyRightNotValid),
56 "sisuValidationFailed" => WireOutcome::Failure(Code::SisuValidationFailed),
57 "sisuTimeout" => WireOutcome::Failure(Code::SisuTimeout),
58 "misregistered" => WireOutcome::Failure(Code::Misregistered),
59 "unauthorized" => WireOutcome::Failure(Code::Unauthorized),
60 "malformedRequest" => WireOutcome::Failure(Code::MalformedRequest),
61 "serviceTemporarilyUnavailable" => {
62 WireOutcome::Failure(Code::ServiceTemporarilyUnavailable)
63 }
64 _ => WireOutcome::Failure(Code::Unknown),
65 }
66}
67
68pub(super) fn outcome_of(endpoint: SuotarEndpoint, code: &str) -> WireOutcome {
71 let outcome = wire_outcome(code);
72 if endpoint != SuotarEndpoint::ImportAttainments {
73 return outcome;
74 }
75 match outcome {
76 WireOutcome::Failure(code) if retryability(code) == Retryability::RetryableTransient => {
79 WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout)
80 }
81 WireOutcome::Settled(CreditRegistrationState::Registered) => {
83 WireOutcome::Failure(CreditRegistrationErrorCode::Unknown)
84 }
85 outcome => outcome,
86 }
87}
88
89fn is_service_unavailable_code(endpoint: SuotarEndpoint, code: &str) -> bool {
94 match wire_outcome(code) {
95 WireOutcome::Failure(CreditRegistrationErrorCode::ServiceTemporarilyUnavailable) => true,
96 WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout) => {
97 endpoint == SuotarEndpoint::ImportAttainments
98 }
99 _ => false,
100 }
101}
102
103fn is_sisu_timeout_code(endpoint: SuotarEndpoint, code: &str) -> bool {
105 endpoint == SuotarEndpoint::ImportAttainments
106 && wire_outcome(code) == WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout)
107}
108
109pub(super) fn is_all_unavailable<'a>(
112 endpoint: SuotarEndpoint,
113 items: impl IntoIterator<Item = (SuotarItemStatus, &'a str)>,
114) -> bool {
115 let mut items = items.into_iter().peekable();
116 items.peek().is_some()
117 && items.all(|(status, code)| {
118 status == SuotarItemStatus::Error && is_service_unavailable_code(endpoint, code)
119 })
120}
121
122pub(super) fn is_only_sisu_timeouts<'a>(
124 endpoint: SuotarEndpoint,
125 codes: impl IntoIterator<Item = &'a str>,
126) -> bool {
127 codes
128 .into_iter()
129 .all(|code| is_sisu_timeout_code(endpoint, code))
130}
131
132pub fn is_waiting_item(endpoint: SuotarEndpoint, code: &str) -> bool {
136 match outcome_of(endpoint, code) {
137 WireOutcome::Unsettled => true,
138 WireOutcome::Failure(code) => is_waiting_error(code),
139 WireOutcome::Settled(_) => false,
140 }
141}
142
143pub(super) fn map_code(
146 endpoint: SuotarEndpoint,
147 code: &str,
148) -> Option<CreditRegistrationErrorCode> {
149 match outcome_of(endpoint, code) {
150 WireOutcome::Failure(code) => Some(code),
151 _ => None,
152 }
153}
154
155pub(super) fn settled_state(
158 endpoint: SuotarEndpoint,
159 code: &str,
160) -> Option<CreditRegistrationState> {
161 match outcome_of(endpoint, code) {
162 WireOutcome::Settled(state) => Some(state),
163 _ => None,
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use CreditRegistrationErrorCode as Code;
171
172 #[test]
173 fn only_the_unavailability_codes_read_as_the_registry_being_unreachable() {
174 assert!(is_service_unavailable_code(
175 SuotarEndpoint::VerifyAttainments,
176 "serviceTemporarilyUnavailable"
177 ));
178 assert!(is_service_unavailable_code(
179 SuotarEndpoint::ImportAttainments,
180 "sisuTimeout"
181 ));
182 assert!(!is_service_unavailable_code(
183 SuotarEndpoint::VerifyAttainments,
184 "notRegistered"
185 ));
186 assert!(!is_service_unavailable_code(
187 SuotarEndpoint::VerifyAttainments,
188 "sisuTimeout"
189 ));
190 }
191
192 #[test]
193 fn every_documented_error_code_maps() {
194 let cases = [
195 (
196 SuotarEndpoint::ResolvePersons,
197 "personNotFound",
198 Code::PersonNotFound,
199 ),
200 (
201 SuotarEndpoint::ResolvePersons,
202 "serviceTemporarilyUnavailable",
203 Code::ServiceTemporarilyUnavailable,
204 ),
205 (
206 SuotarEndpoint::ResolveEnrolments,
207 "personNotFound",
208 Code::PersonNotFound,
209 ),
210 (
211 SuotarEndpoint::ResolveEnrolments,
212 "courseCodeNotFound",
213 Code::CourseCodeNotFound,
214 ),
215 (
216 SuotarEndpoint::ResolveEnrolments,
217 "enrolmentNotFound",
218 Code::EnrolmentNotFound,
219 ),
220 (
221 SuotarEndpoint::ResolveEnrolments,
222 "enrolmentNotAccepted",
223 Code::EnrolmentNotAccepted,
224 ),
225 (
226 SuotarEndpoint::ImportAttainments,
227 "invalidGradeForGradeScale",
228 Code::InvalidGradeForGradeScale,
229 ),
230 (
231 SuotarEndpoint::ImportAttainments,
232 "courseNotAllowed",
233 Code::CourseNotAllowed,
234 ),
235 (
236 SuotarEndpoint::ImportAttainments,
237 "invalidCredits",
238 Code::InvalidCredits,
239 ),
240 (
241 SuotarEndpoint::ImportAttainments,
242 "studyRightNotValid",
243 Code::StudyRightNotValid,
244 ),
245 (
246 SuotarEndpoint::ImportAttainments,
247 "gradeScaleMismatch",
248 Code::GradeScaleMismatch,
249 ),
250 (
251 SuotarEndpoint::ImportAttainments,
252 "registered",
253 Code::Unknown,
254 ),
255 (
256 SuotarEndpoint::VerifyAttainments,
257 "notRegistered",
258 Code::NotRegistered,
259 ),
260 (
261 SuotarEndpoint::ImportAttainments,
262 "sisuValidationFailed",
263 Code::SisuValidationFailed,
264 ),
265 (
266 SuotarEndpoint::ImportAttainments,
267 "sisuTimeout",
268 Code::SisuTimeout,
269 ),
270 (
271 SuotarEndpoint::VerifyAttainments,
272 "misregistered",
273 Code::Misregistered,
274 ),
275 (
276 SuotarEndpoint::VerifyAttainments,
277 "serviceTemporarilyUnavailable",
278 Code::ServiceTemporarilyUnavailable,
279 ),
280 (
281 SuotarEndpoint::ListByCourse,
282 "courseCodeNotFound",
283 Code::CourseCodeNotFound,
284 ),
285 (
286 SuotarEndpoint::ResolvePersons,
287 "unauthorized",
288 Code::Unauthorized,
289 ),
290 (
291 SuotarEndpoint::ResolvePersons,
292 "malformedRequest",
293 Code::MalformedRequest,
294 ),
295 ];
296 for (endpoint, code, expected) in cases {
297 assert_eq!(
298 map_code(endpoint, code),
299 Some(expected),
300 "{code} on {endpoint:?}"
301 );
302 }
303 }
304
305 #[test]
306 fn no_code_that_needs_no_recording_becomes_an_error() {
307 for (endpoint, code) in [
308 (SuotarEndpoint::ResolvePersons, "personFound"),
309 (SuotarEndpoint::ResolveEnrolments, "enrolmentFound"),
310 (SuotarEndpoint::ImportAttainments, "sent"),
311 (SuotarEndpoint::ImportAttainments, "duplicateRequestItem"),
312 (SuotarEndpoint::ImportAttainments, "duplicateAttainment"),
313 (SuotarEndpoint::ImportAttainments, "notImprovedAttainment"),
314 (SuotarEndpoint::VerifyAttainments, "registered"),
315 (SuotarEndpoint::VerifyAttainments, "submissionPending"),
316 (SuotarEndpoint::ListByCourse, "enrolmentsListed"),
317 (SuotarEndpoint::ValidateCourseCodes, "courseAllowed"),
318 ] {
319 assert_eq!(map_code(endpoint, code), None, "{code} on {endpoint:?}");
320 }
321 }
322
323 #[test]
324 fn an_item_level_transient_on_import_is_uncertain_rather_than_retryable() {
325 for code in [
326 "serviceTemporarilyUnavailable",
327 "notRegistered",
328 "unauthorized",
329 "malformedRequest",
330 ] {
331 assert_eq!(
332 map_code(SuotarEndpoint::ImportAttainments, code),
333 Some(Code::SisuTimeout),
334 "{code}"
335 );
336 }
337 }
338
339 #[test]
342 fn every_code_that_settles_a_row_names_the_state_it_settles_it_in() {
343 use CreditRegistrationState as State;
344 for (code, expected) in [
345 ("sent", State::AwaitingVerification),
346 ("duplicateRequestItem", State::AwaitingVerification),
347 ("duplicateAttainment", State::Duplicate),
348 ("notImprovedAttainment", State::NotImproved),
349 ] {
350 assert_eq!(
351 settled_state(SuotarEndpoint::ImportAttainments, code),
352 Some(expected),
353 "{code}"
354 );
355 }
356 assert_eq!(
357 settled_state(SuotarEndpoint::VerifyAttainments, "registered"),
358 Some(State::Registered)
359 );
360 assert_eq!(
361 settled_state(SuotarEndpoint::ImportAttainments, "registered"),
362 None
363 );
364 for code in [
365 "notRegistered",
366 "submissionPending",
367 "personFound",
368 "sisuTimeout",
369 ] {
370 assert_eq!(
371 settled_state(SuotarEndpoint::VerifyAttainments, code),
372 None,
373 "{code}"
374 );
375 }
376 }
377
378 #[test]
379 fn only_not_yet_answers_count_as_waiting() {
380 for (endpoint, code) in [
381 (SuotarEndpoint::VerifyAttainments, "submissionPending"),
382 (SuotarEndpoint::VerifyAttainments, "notRegistered"),
383 (SuotarEndpoint::ResolveEnrolments, "enrolmentNotFound"),
384 (SuotarEndpoint::ResolveEnrolments, "enrolmentNotAccepted"),
385 ] {
386 assert!(is_waiting_item(endpoint, code), "{code} on {endpoint:?}");
387 }
388 for (endpoint, code) in [
389 (SuotarEndpoint::ImportAttainments, "notRegistered"),
390 (SuotarEndpoint::VerifyAttainments, "misregistered"),
391 (SuotarEndpoint::ResolvePersons, "personNotFound"),
392 (SuotarEndpoint::ImportAttainments, "duplicateRequestItem"),
393 (
394 SuotarEndpoint::VerifyAttainments,
395 "somethingSuotarAddedLater",
396 ),
397 ] {
398 assert!(!is_waiting_item(endpoint, code), "{code} on {endpoint:?}");
399 }
400 }
401
402 #[test]
403 fn a_code_suotar_adds_later_maps_to_unknown_rather_than_failing() {
404 assert_eq!(
405 map_code(
406 SuotarEndpoint::VerifyAttainments,
407 "somethingSuotarAddedLater"
408 ),
409 Some(Code::Unknown)
410 );
411 }
412
413 #[test]
414 fn an_answer_is_all_unavailable_only_when_every_item_errs_unavailable() {
415 use SuotarItemStatus::{Error, Ok};
416 let endpoint = SuotarEndpoint::VerifyAttainments;
417 assert!(is_all_unavailable(
418 endpoint,
419 [(Error, "serviceTemporarilyUnavailable")]
420 ));
421 assert!(!is_all_unavailable(
422 endpoint,
423 [(Error, "serviceTemporarilyUnavailable"), (Ok, "registered")]
424 ));
425 assert!(!is_all_unavailable(endpoint, []));
426 }
427}