1use utoipa::ToSchema;
6
7use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
8use crate::prelude::*;
9use crate::suotar_api_calls::SuotarEndpoint;
10
11#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum Retryability {
16 RetryableTransient,
17 VerifyOnly,
19 PermanentNeedsStudent,
20 PermanentNeedsAdmin,
21 PermanentNeedsConfig,
22}
23
24pub fn retryability(code: CreditRegistrationErrorCode) -> Retryability {
25 use CreditRegistrationErrorCode as Code;
26 use Retryability as Class;
27 match code {
28 Code::SisuTemporarilyUnavailable | Code::TransportError => Class::RetryableTransient,
29 Code::Unauthorized | Code::MalformedRequest => Class::RetryableTransient,
31 Code::UnexpectedResponse => Class::RetryableTransient,
33 Code::SisuTimeout => Class::VerifyOnly,
34 Code::PersonNotFound
35 | Code::EnrolmentNotFound
36 | Code::EnrolmentNotAccepted
37 | Code::StudyRightNotValid => Class::PermanentNeedsStudent,
38 Code::CourseCodeNotFound
39 | Code::CourseNotAllowed
40 | Code::InvalidGradeForGradeScale
41 | Code::InvalidCredits
42 | Code::NoGradeScaleMapping
43 | Code::MissingUhCourseCode
44 | Code::MissingEctsCredits => Class::PermanentNeedsConfig,
45 Code::AcceptorNotFound
46 | Code::SisuValidationFailed
47 | Code::Misregistered
48 | Code::RetryWindowExpired
49 | Code::Unknown => Class::PermanentNeedsAdmin,
50 }
51}
52
53#[derive(Debug, PartialEq, Eq, Clone, Copy)]
55pub enum WireOutcome {
56 Settled(CreditRegistrationState),
58 Unsettled,
61 Failure(CreditRegistrationErrorCode),
62}
63
64fn wire_outcome(code: &str) -> WireOutcome {
69 use CreditRegistrationErrorCode as Code;
70 use CreditRegistrationState as State;
71 match code {
72 "sent" => WireOutcome::Settled(State::AwaitingVerification),
75 "registered" => WireOutcome::Settled(State::Registered),
76 "duplicateAttainment" => WireOutcome::Settled(State::Duplicate),
77 "notImprovedAttainment" => WireOutcome::Settled(State::NotImproved),
78 "personFound" | "enrolmentFound" | "found" | "enrolmentsListed" | "notRegistered" => {
79 WireOutcome::Unsettled
80 }
81 "personNotFound" => WireOutcome::Failure(Code::PersonNotFound),
82 "courseCodeNotFound" => WireOutcome::Failure(Code::CourseCodeNotFound),
83 "enrolmentNotFound" => WireOutcome::Failure(Code::EnrolmentNotFound),
84 "enrolmentNotAccepted" => WireOutcome::Failure(Code::EnrolmentNotAccepted),
85 "invalidGradeForGradeScale" => WireOutcome::Failure(Code::InvalidGradeForGradeScale),
86 "courseNotAllowed" => WireOutcome::Failure(Code::CourseNotAllowed),
87 "invalidCredits" => WireOutcome::Failure(Code::InvalidCredits),
88 "studyRightNotValid" => WireOutcome::Failure(Code::StudyRightNotValid),
89 "acceptorNotFound" => WireOutcome::Failure(Code::AcceptorNotFound),
90 "sisuValidationFailed" => WireOutcome::Failure(Code::SisuValidationFailed),
91 "sisuTimeout" => WireOutcome::Failure(Code::SisuTimeout),
92 "misregistered" => WireOutcome::Failure(Code::Misregistered),
93 "unauthorized" => WireOutcome::Failure(Code::Unauthorized),
94 "malformedRequest" => WireOutcome::Failure(Code::MalformedRequest),
95 "sisuTemporarilyUnavailable" => WireOutcome::Failure(Code::SisuTemporarilyUnavailable),
96 _ => WireOutcome::Failure(Code::Unknown),
97 }
98}
99
100pub fn outcome_of(endpoint: SuotarEndpoint, code: &str) -> WireOutcome {
103 let outcome = wire_outcome(code);
104 if endpoint == SuotarEndpoint::ImportAttainments
107 && outcome == WireOutcome::Failure(CreditRegistrationErrorCode::SisuTemporarilyUnavailable)
108 {
109 return WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout);
110 }
111 outcome
112}
113
114pub fn wire_code_retryability(code: &str) -> Option<Retryability> {
117 match wire_outcome(code) {
118 WireOutcome::Failure(code) => Some(retryability(code)),
119 _ => None,
120 }
121}
122
123pub fn is_retryable_transient_wire_code(code: &str) -> bool {
124 wire_code_retryability(code) == Some(Retryability::RetryableTransient)
125}
126
127pub fn map_code(endpoint: SuotarEndpoint, code: &str) -> Option<CreditRegistrationErrorCode> {
130 match outcome_of(endpoint, code) {
131 WireOutcome::Failure(code) => Some(code),
132 _ => None,
133 }
134}
135
136pub fn settled_state(endpoint: SuotarEndpoint, code: &str) -> Option<CreditRegistrationState> {
138 match outcome_of(endpoint, code) {
139 WireOutcome::Settled(state) => Some(state),
140 _ => None,
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use CreditRegistrationErrorCode as Code;
148 use Retryability as Class;
149
150 #[test]
153 fn retryability_matches_the_documented_class_for_every_code() {
154 let cases = [
155 (Code::SisuTemporarilyUnavailable, Class::RetryableTransient),
156 (Code::TransportError, Class::RetryableTransient),
157 (Code::Unauthorized, Class::RetryableTransient),
158 (Code::MalformedRequest, Class::RetryableTransient),
159 (Code::UnexpectedResponse, Class::RetryableTransient),
160 (Code::SisuTimeout, Class::VerifyOnly),
161 (Code::PersonNotFound, Class::PermanentNeedsStudent),
162 (Code::EnrolmentNotFound, Class::PermanentNeedsStudent),
163 (Code::EnrolmentNotAccepted, Class::PermanentNeedsStudent),
164 (Code::StudyRightNotValid, Class::PermanentNeedsStudent),
165 (Code::CourseCodeNotFound, Class::PermanentNeedsConfig),
166 (Code::CourseNotAllowed, Class::PermanentNeedsConfig),
167 (Code::InvalidGradeForGradeScale, Class::PermanentNeedsConfig),
168 (Code::InvalidCredits, Class::PermanentNeedsConfig),
169 (Code::NoGradeScaleMapping, Class::PermanentNeedsConfig),
170 (Code::MissingUhCourseCode, Class::PermanentNeedsConfig),
171 (Code::MissingEctsCredits, Class::PermanentNeedsConfig),
172 (Code::AcceptorNotFound, Class::PermanentNeedsAdmin),
173 (Code::SisuValidationFailed, Class::PermanentNeedsAdmin),
174 (Code::Misregistered, Class::PermanentNeedsAdmin),
175 (Code::RetryWindowExpired, Class::PermanentNeedsAdmin),
176 (Code::Unknown, Class::PermanentNeedsAdmin),
177 ];
178 assert_eq!(
179 cases.len(),
180 CreditRegistrationErrorCode::ALL.len(),
181 "every code must be covered"
182 );
183 for (code, expected) in cases {
184 assert_eq!(retryability(code), expected, "{code:?}");
185 }
186 }
187
188 #[test]
191 fn the_wire_class_of_the_transient_code_ignores_imports_hardening() {
192 assert!(is_retryable_transient_wire_code(
193 "sisuTemporarilyUnavailable"
194 ));
195 assert_eq!(
196 map_code(
197 SuotarEndpoint::ImportAttainments,
198 "sisuTemporarilyUnavailable"
199 )
200 .map(retryability),
201 Some(Class::VerifyOnly)
202 );
203 }
204
205 #[test]
206 fn a_code_that_names_no_failure_has_no_wire_class() {
207 assert_eq!(wire_code_retryability("registered"), None);
208 assert_eq!(wire_code_retryability("notRegistered"), None);
209 }
210
211 #[test]
212 fn every_documented_error_code_maps() {
213 use CreditRegistrationErrorCode as Code;
214 let cases = [
215 (
216 SuotarEndpoint::ResolvePersons,
217 "personNotFound",
218 Code::PersonNotFound,
219 ),
220 (
221 SuotarEndpoint::ResolvePersons,
222 "sisuTemporarilyUnavailable",
223 Code::SisuTemporarilyUnavailable,
224 ),
225 (
226 SuotarEndpoint::ResolveEnrolments,
227 "personNotFound",
228 Code::PersonNotFound,
229 ),
230 (
231 SuotarEndpoint::ResolveEnrolments,
232 "courseCodeNotFound",
233 Code::CourseCodeNotFound,
234 ),
235 (
236 SuotarEndpoint::ResolveEnrolments,
237 "enrolmentNotFound",
238 Code::EnrolmentNotFound,
239 ),
240 (
241 SuotarEndpoint::ResolveEnrolments,
242 "enrolmentNotAccepted",
243 Code::EnrolmentNotAccepted,
244 ),
245 (
246 SuotarEndpoint::ImportAttainments,
247 "invalidGradeForGradeScale",
248 Code::InvalidGradeForGradeScale,
249 ),
250 (
251 SuotarEndpoint::ImportAttainments,
252 "courseNotAllowed",
253 Code::CourseNotAllowed,
254 ),
255 (
256 SuotarEndpoint::ImportAttainments,
257 "invalidCredits",
258 Code::InvalidCredits,
259 ),
260 (
261 SuotarEndpoint::ImportAttainments,
262 "studyRightNotValid",
263 Code::StudyRightNotValid,
264 ),
265 (
266 SuotarEndpoint::ImportAttainments,
267 "acceptorNotFound",
268 Code::AcceptorNotFound,
269 ),
270 (
271 SuotarEndpoint::ImportAttainments,
272 "sisuValidationFailed",
273 Code::SisuValidationFailed,
274 ),
275 (
276 SuotarEndpoint::ImportAttainments,
277 "sisuTimeout",
278 Code::SisuTimeout,
279 ),
280 (
281 SuotarEndpoint::VerifyAttainments,
282 "misregistered",
283 Code::Misregistered,
284 ),
285 (
286 SuotarEndpoint::VerifyAttainments,
287 "sisuTemporarilyUnavailable",
288 Code::SisuTemporarilyUnavailable,
289 ),
290 (
291 SuotarEndpoint::ListByCourse,
292 "courseCodeNotFound",
293 Code::CourseCodeNotFound,
294 ),
295 (
296 SuotarEndpoint::ResolvePersons,
297 "unauthorized",
298 Code::Unauthorized,
299 ),
300 (
301 SuotarEndpoint::ResolvePersons,
302 "malformedRequest",
303 Code::MalformedRequest,
304 ),
305 ];
306 for (endpoint, code, expected) in cases {
307 assert_eq!(
308 map_code(endpoint, code),
309 Some(expected),
310 "{code} on {endpoint:?}"
311 );
312 }
313 }
314
315 #[test]
316 fn no_code_that_needs_no_recording_becomes_an_error() {
317 for (endpoint, code) in [
318 (SuotarEndpoint::ResolvePersons, "personFound"),
319 (SuotarEndpoint::ResolveEnrolments, "enrolmentFound"),
320 (SuotarEndpoint::ImportAttainments, "sent"),
321 (SuotarEndpoint::ImportAttainments, "registered"),
322 (SuotarEndpoint::ImportAttainments, "duplicateAttainment"),
323 (SuotarEndpoint::ImportAttainments, "notImprovedAttainment"),
324 (SuotarEndpoint::VerifyAttainments, "registered"),
325 (SuotarEndpoint::ProductAccessTokens, "found"),
326 (SuotarEndpoint::ListByCourse, "enrolmentsListed"),
327 (SuotarEndpoint::VerifyAttainments, "notRegistered"),
328 ] {
329 assert_eq!(map_code(endpoint, code), None, "{code} on {endpoint:?}");
330 }
331 }
332
333 #[test]
334 fn an_item_level_transient_on_import_is_uncertain_rather_than_retryable() {
335 assert_eq!(
336 map_code(
337 SuotarEndpoint::ImportAttainments,
338 "sisuTemporarilyUnavailable"
339 ),
340 Some(CreditRegistrationErrorCode::SisuTimeout)
341 );
342 }
343
344 #[test]
347 fn every_code_that_settles_a_row_names_the_state_it_settles_it_in() {
348 use CreditRegistrationState as State;
349 for (code, expected) in [
350 ("sent", State::AwaitingVerification),
351 ("registered", State::Registered),
352 ("duplicateAttainment", State::Duplicate),
353 ("notImprovedAttainment", State::NotImproved),
354 ] {
355 assert_eq!(
356 settled_state(SuotarEndpoint::ImportAttainments, code),
357 Some(expected),
358 "{code}"
359 );
360 }
361 assert_eq!(
362 settled_state(SuotarEndpoint::VerifyAttainments, "registered"),
363 Some(State::Registered)
364 );
365 for code in ["notRegistered", "personFound", "sisuTimeout"] {
366 assert_eq!(
367 settled_state(SuotarEndpoint::VerifyAttainments, code),
368 None,
369 "{code}"
370 );
371 }
372 }
373
374 #[test]
375 fn a_code_suotar_adds_later_maps_to_unknown_rather_than_failing() {
376 assert_eq!(
377 map_code(
378 SuotarEndpoint::VerifyAttainments,
379 "somethingSuotarAddedLater"
380 ),
381 Some(CreditRegistrationErrorCode::Unknown)
382 );
383 }
384}