1use crate::credit_registrations::{
8 AdminAttention, CreditRegistration, CreditRegistrationErrorCode, CreditRegistrationState,
9 Transition,
10};
11use crate::prelude::*;
12use chrono::TimeDelta;
13
14use super::backoff::{
15 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER, UNCERTAIN_RECHECK,
16 VERIFY_GIVE_UP_POLL, next_attempt_at, submit_backoff, submit_window_expired,
17 uncertain_needs_admin, uncertain_recheck_delay, verify_delay, verify_window_expired,
18};
19use super::classification::{Retryability, is_waiting_error, retryability};
20use super::enrolment_check_schedule::TRANSIENT_FAILURE_RETRY;
21use super::study_registry::{RegistryErrorKind, RegistryOperation};
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct RowFacts {
26 pub now: DateTime<Utc>,
27 pub first_failed_at: Option<DateTime<Utc>>,
28 pub submit_retry_count: i32,
29 pub verify_attempt_count: i32,
30 pub submitted_at: Option<DateTime<Utc>>,
31 pub is_waiting_for_enrolment: bool,
33 pub error_code: Option<CreditRegistrationErrorCode>,
35 pub course_unit_poll_at: Option<DateTime<Utc>>,
38}
39
40impl RowFacts {
41 pub fn of(row: &CreditRegistration, now: DateTime<Utc>) -> Self {
43 Self {
44 now,
45 first_failed_at: row.first_failed_at,
46 submit_retry_count: row.submit_retry_count,
47 verify_attempt_count: row.verify_attempt_count,
48 submitted_at: row.submitted_at,
49 is_waiting_for_enrolment: row.is_waiting_for_enrolment(),
50 error_code: row.error_code,
51 course_unit_poll_at: None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum NextAttempt {
59 StateDefault,
61 After(TimeDelta),
63 At(DateTime<Utc>),
64 NextEnrolmentRung,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct Outcome {
72 pub to_state: CreditRegistrationState,
73 pub error_code: Option<CreditRegistrationErrorCode>,
74 pub needs_admin_attention: Option<AdminAttention>,
76 pub next: NextAttempt,
77 pub drop_verified_student_number: bool,
79 pub increment_submit_retry_count: bool,
80 pub keeps_enrolment_checked_at: bool,
83}
84
85impl Outcome {
86 pub fn to(to_state: CreditRegistrationState) -> Self {
87 Self {
88 to_state,
89 error_code: None,
90 needs_admin_attention: None,
91 next: NextAttempt::StateDefault,
92 drop_verified_student_number: false,
93 increment_submit_retry_count: false,
94 keeps_enrolment_checked_at: false,
95 }
96 }
97
98 pub fn is_failure(&self) -> bool {
102 self.error_code.is_some_and(|code| !is_waiting_error(code))
103 }
104
105 pub fn is_waiting(&self) -> bool {
108 self.error_code.is_some_and(is_waiting_error)
109 }
110
111 pub fn transition(
114 &self,
115 expected_from_state: Option<CreditRegistrationState>,
116 now: DateTime<Utc>,
117 ) -> Transition {
118 Transition {
119 error_code: self.error_code,
120 needs_admin_attention: self.needs_admin_attention,
121 expected_from_state,
122 next_attempt_at: match self.next {
123 NextAttempt::StateDefault | NextAttempt::NextEnrolmentRung => None,
124 NextAttempt::After(delay) => Some(next_attempt_at(now, delay)),
125 NextAttempt::At(at) => Some(at),
126 },
127 keeps_enrolment_checked_at: self.keeps_enrolment_checked_at,
128 ..Transition::to(self.to_state)
129 }
130 }
131
132 pub(super) fn with_code(self, error_code: CreditRegistrationErrorCode) -> Self {
133 Self {
134 error_code: Some(error_code),
135 ..self
136 }
137 }
138
139 pub(super) fn needing_admin(self) -> Self {
140 Self {
141 needs_admin_attention: Some(AdminAttention::Raise),
142 ..self
143 }
144 }
145
146 fn after(self, delay: TimeDelta) -> Self {
147 Self {
148 next: NextAttempt::After(delay),
149 ..self
150 }
151 }
152}
153
154pub fn submission_uncertain() -> Outcome {
157 Outcome::to(CreditRegistrationState::SubmissionUncertain)
158 .with_code(CreditRegistrationErrorCode::SisuTimeout)
159 .after(UNCERTAIN_RECHECK)
160}
161
162pub fn submit_error_outcome(
165 operation: RegistryOperation,
166 code: CreditRegistrationErrorCode,
167 facts: &RowFacts,
168) -> Outcome {
169 use CreditRegistrationErrorCode as Code;
170 if operation.creates_attainments() && code == Code::Unknown {
173 return submission_uncertain();
174 }
175 match retryability(code) {
176 Retryability::VerifyOnly if operation.creates_attainments() => submission_uncertain(),
177 Retryability::VerifyOnly | Retryability::RetryableTransient => {
178 retry_or_expire(code, operation, facts, Failure::Transient)
179 }
180 Retryability::PermanentNeedsStudent => match code {
181 Code::PersonNotFound => Outcome {
184 drop_verified_student_number: true,
185 ..Outcome::to(CreditRegistrationState::Pending).with_code(code)
186 },
187 _ => Outcome {
188 next: NextAttempt::NextEnrolmentRung,
189 ..Outcome::to(CreditRegistrationState::NoUsableEnrolment).with_code(code)
190 },
191 },
192 Retryability::PermanentNeedsConfig | Retryability::PermanentNeedsAdmin => {
193 Outcome::to(CreditRegistrationState::FailedPermanent)
194 .with_code(code)
195 .needing_admin()
196 }
197 }
198}
199
200pub fn verify_error_outcome(
204 state: CreditRegistrationState,
205 code: CreditRegistrationErrorCode,
206 facts: &RowFacts,
207) -> Outcome {
208 if code == CreditRegistrationErrorCode::Misregistered {
209 return Outcome::to(CreditRegistrationState::Misregistered)
210 .with_code(code)
211 .needing_admin();
212 }
213 verify_inconclusive_outcome(state, facts)
214}
215
216pub fn verify_inconclusive_outcome(state: CreditRegistrationState, facts: &RowFacts) -> Outcome {
218 if verify_window_expired(facts.submitted_at, facts.now) {
219 return Outcome::to(state)
220 .after(VERIFY_GIVE_UP_POLL)
221 .needing_admin();
222 }
223 still_verifying(state, facts)
224}
225
226fn still_verifying(state: CreditRegistrationState, facts: &RowFacts) -> Outcome {
229 Outcome {
230 next: course_unit_poll_at(state, facts).map_or_else(
231 || NextAttempt::After(verify_delay(facts.submitted_at, facts.now)),
232 NextAttempt::At,
233 ),
234 ..Outcome::to(state)
235 }
236}
237
238fn course_unit_poll_at(state: CreditRegistrationState, facts: &RowFacts) -> Option<DateTime<Utc>> {
240 facts
241 .course_unit_poll_at
242 .filter(|_| state == CreditRegistrationState::PartiallyRegistered)
243}
244
245pub fn verify_partial_outcome(facts: &RowFacts, partially_registered_at: DateTime<Utc>) -> Outcome {
249 let outcome = still_verifying(CreditRegistrationState::PartiallyRegistered, facts);
250 if facts.now - partially_registered_at >= PARTIAL_REGISTRATION_ADMIN_AFTER {
251 outcome.needing_admin()
252 } else {
253 outcome
254 }
255}
256
257pub fn verify_not_registered_outcome(facts: &RowFacts, reimport_count: i32) -> Outcome {
261 let outcome = Outcome {
262 increment_submit_retry_count: true,
263 ..Outcome::to(CreditRegistrationState::FailedRetryable)
264 .with_code(CreditRegistrationErrorCode::NotRegistered)
265 .after(submit_backoff(facts.submit_retry_count))
266 };
267 if reimport_count >= NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD {
268 outcome.needing_admin()
269 } else {
270 outcome
271 }
272}
273
274pub fn uncertain_recheck_outcome(facts: &RowFacts) -> Outcome {
277 let outcome = Outcome::to(CreditRegistrationState::SubmissionUncertain)
278 .after(uncertain_recheck_delay(facts.verify_attempt_count));
279 if uncertain_needs_admin(facts.submitted_at, facts.now) {
280 outcome.needing_admin()
281 } else {
282 outcome
283 }
284}
285
286pub fn request_level_outcome(
291 operation: RegistryOperation,
292 kind: RegistryErrorKind,
293 facts: &RowFacts,
294) -> Outcome {
295 if operation.creates_attainments() && kind.may_have_been_acted_on() {
296 return submission_uncertain();
297 }
298 let failure = if kind.is_outage() {
299 Failure::Transient
300 } else {
301 Failure::Lasting
302 };
303 retry_or_expire(request_level_code(kind), operation, facts, failure)
304}
305
306fn waiting_lookup_failed(operation: RegistryOperation, facts: &RowFacts) -> Option<Outcome> {
310 let is_lookup = matches!(
311 operation,
312 RegistryOperation::ResolveEnrolments | RegistryOperation::ResolvePersons
313 );
314 (is_lookup && facts.is_waiting_for_enrolment).then(|| Outcome {
315 error_code: facts.error_code,
316 keeps_enrolment_checked_at: true,
317 ..Outcome::to(CreditRegistrationState::NoUsableEnrolment).after(TRANSIENT_FAILURE_RETRY)
318 })
319}
320
321pub fn isolated_malformed_request_outcome() -> Outcome {
324 Outcome::to(CreditRegistrationState::FailedPermanent)
325 .with_code(CreditRegistrationErrorCode::MalformedRequest)
326 .needing_admin()
327}
328
329pub fn unanswered_item_outcome(
332 operation: RegistryOperation,
333 state: CreditRegistrationState,
334 facts: &RowFacts,
335) -> Outcome {
336 if operation.creates_attainments() {
337 return submission_uncertain();
338 }
339 if operation == RegistryOperation::VerifyAttainments {
340 return verify_inconclusive_outcome(state, facts);
341 }
342 retry_or_expire(
343 CreditRegistrationErrorCode::UnexpectedResponse,
344 operation,
345 facts,
346 Failure::Transient,
347 )
348}
349
350pub fn request_level_code(kind: RegistryErrorKind) -> CreditRegistrationErrorCode {
352 match kind {
353 RegistryErrorKind::AuthenticationFailure => CreditRegistrationErrorCode::Unauthorized,
354 RegistryErrorKind::MalformedRequest => CreditRegistrationErrorCode::MalformedRequest,
355 RegistryErrorKind::ProtocolViolation | RegistryErrorKind::RejectedRequest => {
356 CreditRegistrationErrorCode::UnexpectedResponse
357 }
358 RegistryErrorKind::TemporarilyUnavailable | RegistryErrorKind::ServerError => {
360 CreditRegistrationErrorCode::ServiceTemporarilyUnavailable
361 }
362 RegistryErrorKind::NotDelivered | RegistryErrorKind::NoAnswer => {
363 CreditRegistrationErrorCode::TransportError
364 }
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum Failure {
371 Transient,
372 Lasting,
374}
375
376fn retry_or_expire(
380 code: CreditRegistrationErrorCode,
381 operation: RegistryOperation,
382 facts: &RowFacts,
383 failure: Failure,
384) -> Outcome {
385 if failure == Failure::Transient
386 && let Some(outcome) = waiting_lookup_failed(operation, facts)
387 {
388 return outcome;
389 }
390 if submit_window_expired(facts.first_failed_at, facts.now) {
391 return Outcome::to(CreditRegistrationState::FailedPermanent)
392 .with_code(CreditRegistrationErrorCode::RetryWindowExpired)
393 .needing_admin();
394 }
395 let delay = if operation == RegistryOperation::VerifyAttainments {
396 verify_delay(facts.submitted_at, facts.now)
397 } else {
398 submit_backoff(facts.submit_retry_count)
399 };
400 Outcome {
401 increment_submit_retry_count: operation != RegistryOperation::VerifyAttainments,
402 ..Outcome::to(CreditRegistrationState::FailedRetryable)
403 .with_code(code)
404 .after(delay)
405 }
406}
407
408pub fn import_success_outcome(state: CreditRegistrationState, facts: &RowFacts) -> Outcome {
411 let outcome = Outcome::to(state);
412 if state == CreditRegistrationState::AwaitingVerification {
413 return outcome.after(verify_delay(facts.submitted_at, facts.now));
414 }
415 outcome
416}
417
418pub fn verify_poll_lease_until(
423 state: CreditRegistrationState,
424 facts: &RowFacts,
425 calls: TimeDelta,
426) -> DateTime<Utc> {
427 let min_lease = calls + TimeDelta::minutes(5);
428 match course_unit_poll_at(state, facts) {
429 Some(at) => at.max(next_attempt_at(facts.now, min_lease)),
430 None => next_attempt_at(
431 facts.now,
432 verify_delay(facts.submitted_at, facts.now).max(min_lease),
433 ),
434 }
435}
436
437#[derive(Debug, Clone, PartialEq)]
440pub struct UnaskedMove {
441 pub outcome: Outcome,
442 pub message: Option<String>,
443}
444
445impl UnaskedMove {
446 pub(super) fn new(outcome: Outcome, message: impl Into<String>) -> Self {
447 Self {
448 outcome,
449 message: Some(message.into()),
450 }
451 }
452
453 pub(super) fn silent(outcome: Outcome) -> Self {
454 Self {
455 outcome,
456 message: None,
457 }
458 }
459
460 pub fn transition(
462 &self,
463 expected_from_state: Option<CreditRegistrationState>,
464 now: DateTime<Utc>,
465 ) -> Transition {
466 Transition {
467 event_message: self.message.clone(),
468 ..self.outcome.transition(expected_from_state, now)
469 }
470 }
471}
472
473pub const NO_VERIFIED_STUDENT_NUMBER_MESSAGE: &str =
475 "No verified student number is linked to the account.";
476
477pub fn submitting() -> UnaskedMove {
480 UnaskedMove::silent(Outcome::to(CreditRegistrationState::Submitting))
481}
482
483pub fn duplicate_of_other_registrar() -> UnaskedMove {
485 UnaskedMove::new(
486 Outcome::to(CreditRegistrationState::Duplicate),
487 "Another registrar had already registered this completion, so nothing was submitted.",
488 )
489}
490
491pub fn incomplete_payload() -> UnaskedMove {
493 UnaskedMove::new(
494 Outcome::to(CreditRegistrationState::ReadyToSubmit),
495 "The frozen payload is incomplete, so the enrolment is resolved again.",
496 )
497}
498
499pub fn unknown_grade() -> UnaskedMove {
501 UnaskedMove::new(
502 Outcome::to(CreditRegistrationState::FailedPermanent)
503 .with_code(CreditRegistrationErrorCode::NoGradeScaleMapping)
504 .needing_admin(),
505 "Sisu does not accept this grade.",
506 )
507}
508
509pub fn invalid_payload_field(field: &str) -> UnaskedMove {
511 UnaskedMove::new(
512 Outcome::to(CreditRegistrationState::FailedPermanent)
513 .with_code(CreditRegistrationErrorCode::Unknown)
514 .needing_admin(),
515 format!("Sisu does not accept the {field} we would send, so nothing was sent."),
516 )
517}
518
519pub fn released_unsent_split_half() -> UnaskedMove {
521 UnaskedMove::new(
522 Outcome::to(CreditRegistrationState::Pending),
523 "The split batch this row was in stopped before its part was sent, so nothing was \
524 submitted.",
525 )
526}
527
528pub fn no_verified_student_number() -> UnaskedMove {
530 UnaskedMove::new(
531 Outcome::to(CreditRegistrationState::Pending),
532 NO_VERIFIED_STUDENT_NUMBER_MESSAGE,
533 )
534}
535
536pub fn module_not_configured(code: CreditRegistrationErrorCode) -> UnaskedMove {
538 UnaskedMove::new(
539 Outcome::to(CreditRegistrationState::FailedPermanent)
540 .with_code(code)
541 .needing_admin(),
542 "The module is not configured for credit registration.",
543 )
544}
545
546pub fn resolving_enrolment() -> UnaskedMove {
548 UnaskedMove::silent(Outcome::to(CreditRegistrationState::ResolvingEnrolment))
549}
550
551pub fn missing_context(facts: &RowFacts) -> UnaskedMove {
555 UnaskedMove::new(
556 retry_or_expire(
557 CreditRegistrationErrorCode::Unknown,
558 RegistryOperation::ResolveEnrolments,
559 facts,
560 Failure::Lasting,
561 ),
562 "There is no completion or module to submit for.",
563 )
564}
565
566#[cfg(test)]
567mod tests {
568 use super::super::backoff::{UNCERTAIN_MAX_RECHECK, VERIFY_WINDOW_INTERVAL};
569 use super::*;
570 use CreditRegistrationErrorCode as Code;
571 use CreditRegistrationState as State;
572
573 fn facts() -> RowFacts {
574 RowFacts {
575 now: Utc::now(),
576 first_failed_at: None,
577 submit_retry_count: 0,
578 verify_attempt_count: 0,
579 submitted_at: None,
580 is_waiting_for_enrolment: false,
581 error_code: None,
582 course_unit_poll_at: None,
583 }
584 }
585
586 fn import(code: Code) -> Outcome {
587 submit_error_outcome(RegistryOperation::ImportAttainments, code, &facts())
588 }
589
590 fn resolve(code: Code) -> Outcome {
591 submit_error_outcome(RegistryOperation::ResolveEnrolments, code, &facts())
592 }
593
594 #[test]
595 fn every_error_code_has_an_import_outcome_that_never_resends() {
596 for code in CreditRegistrationErrorCode::ALL {
597 let outcome = import(code);
598 assert!(
599 !matches!(
600 outcome.to_state,
601 State::Submitting | State::CheckingEnrolment
602 ),
603 "{code:?} would put the row back in front of import"
604 );
605 }
606 }
607
608 #[test]
611 fn import_routes_every_code_to_its_documented_state() {
612 let cases = [
613 (Code::ServiceTemporarilyUnavailable, State::FailedRetryable),
614 (Code::NotRegistered, State::FailedRetryable),
615 (Code::TransportError, State::FailedRetryable),
616 (Code::Unauthorized, State::FailedRetryable),
617 (Code::MalformedRequest, State::FailedRetryable),
618 (Code::UnexpectedResponse, State::FailedRetryable),
619 (Code::SisuTimeout, State::SubmissionUncertain),
620 (Code::PersonNotFound, State::Pending),
621 (Code::EnrolmentNotFound, State::NoUsableEnrolment),
622 (Code::EnrolmentNotAccepted, State::NoUsableEnrolment),
623 (Code::StudyRightNotValid, State::NoUsableEnrolment),
624 (Code::CourseCodeNotFound, State::FailedPermanent),
625 (Code::CourseNotAllowed, State::FailedPermanent),
626 (Code::InvalidGradeForGradeScale, State::FailedPermanent),
627 (Code::GradeScaleMismatch, State::FailedPermanent),
628 (Code::InvalidCredits, State::FailedPermanent),
629 (Code::NoGradeScaleMapping, State::FailedPermanent),
630 (Code::MissingUhCourseCode, State::FailedPermanent),
631 (Code::MissingEctsCredits, State::FailedPermanent),
632 (Code::SisuValidationFailed, State::FailedPermanent),
633 (Code::Misregistered, State::FailedPermanent),
634 (Code::RetryWindowExpired, State::FailedPermanent),
635 (Code::Unknown, State::SubmissionUncertain),
636 ];
637 assert_eq!(
638 cases.len(),
639 CreditRegistrationErrorCode::ALL.len(),
640 "every code must be covered"
641 );
642 for (code, expected) in cases {
643 assert_eq!(import(code).to_state, expected, "{code:?}");
644 }
645 }
646
647 #[test]
650 fn the_import_answers_that_allow_another_attempt_are_only_refusals() {
651 let resendable: Vec<Code> = CreditRegistrationErrorCode::ALL
652 .into_iter()
653 .filter(|code| import(*code).to_state == State::FailedRetryable)
654 .collect();
655 assert_eq!(
656 resendable,
657 vec![
658 Code::ServiceTemporarilyUnavailable,
659 Code::NotRegistered,
660 Code::Unauthorized,
661 Code::MalformedRequest,
662 Code::TransportError,
663 Code::UnexpectedResponse,
664 ]
665 );
666 }
667
668 #[test]
670 fn an_import_answer_we_cannot_classify_is_uncertain_rather_than_failed() {
671 assert_eq!(import(Code::Unknown).to_state, State::SubmissionUncertain);
672 assert_eq!(resolve(Code::Unknown).to_state, State::FailedPermanent);
673 }
674
675 #[test]
676 fn a_timeout_is_uncertain_on_import_and_retryable_on_resolve() {
677 assert_eq!(
678 import(Code::SisuTimeout).to_state,
679 State::SubmissionUncertain
680 );
681 assert_eq!(resolve(Code::SisuTimeout).to_state, State::FailedRetryable);
682 }
683
684 #[test]
685 fn a_person_suotar_does_not_know_costs_the_stored_student_number() {
686 let outcome = import(Code::PersonNotFound);
687 assert!(outcome.drop_verified_student_number);
688 assert_eq!(outcome.to_state, State::Pending);
689 for code in CreditRegistrationErrorCode::ALL {
690 if code != Code::PersonNotFound {
691 assert!(!import(code).drop_verified_student_number, "{code:?}");
692 }
693 }
694 }
695
696 #[test]
697 fn a_config_error_asks_for_a_human_and_a_transient_one_does_not() {
698 assert_eq!(
699 import(Code::InvalidGradeForGradeScale).needs_admin_attention,
700 Some(AdminAttention::Raise)
701 );
702 assert_eq!(
703 import(Code::ServiceTemporarilyUnavailable).needs_admin_attention,
704 None
705 );
706 }
707
708 #[test]
709 fn a_row_that_has_been_failing_for_a_week_stops_being_retried() {
710 let facts = RowFacts {
711 first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
712 ..facts()
713 };
714 let outcome = submit_error_outcome(
715 RegistryOperation::ResolveEnrolments,
716 Code::ServiceTemporarilyUnavailable,
717 &facts,
718 );
719 assert_eq!(outcome.to_state, State::FailedPermanent);
720 assert_eq!(outcome.error_code, Some(Code::RetryWindowExpired));
721 }
722
723 #[test]
724 fn an_expired_window_does_not_override_an_uncertain_import() {
725 let facts = RowFacts {
726 first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
727 ..facts()
728 };
729 assert_eq!(
730 submit_error_outcome(
731 RegistryOperation::ImportAttainments,
732 Code::SisuTimeout,
733 &facts
734 )
735 .to_state,
736 State::SubmissionUncertain
737 );
738 }
739
740 #[test]
741 fn only_a_request_that_may_have_reached_business_logic_leaves_an_import_batch_uncertain() {
742 let facts = facts();
743 for kind in [
744 RegistryErrorKind::ServerError,
745 RegistryErrorKind::NoAnswer,
746 RegistryErrorKind::ProtocolViolation,
747 ] {
748 assert_eq!(
749 request_level_outcome(RegistryOperation::ImportAttainments, kind, &facts).to_state,
750 State::SubmissionUncertain,
751 "{kind:?}"
752 );
753 }
754 for kind in [
755 RegistryErrorKind::NotDelivered,
756 RegistryErrorKind::AuthenticationFailure,
757 RegistryErrorKind::MalformedRequest,
758 RegistryErrorKind::RejectedRequest,
759 RegistryErrorKind::TemporarilyUnavailable,
760 ] {
761 assert_eq!(
762 request_level_outcome(RegistryOperation::ImportAttainments, kind, &facts).to_state,
763 State::FailedRetryable,
764 "{kind:?}"
765 );
766 }
767 }
768
769 #[test]
770 fn a_request_level_failure_elsewhere_is_always_a_plain_retry() {
771 let facts = facts();
772 for kind in [
773 RegistryErrorKind::ServerError,
774 RegistryErrorKind::NoAnswer,
775 RegistryErrorKind::AuthenticationFailure,
776 ] {
777 assert_eq!(
778 request_level_outcome(RegistryOperation::ResolveEnrolments, kind, &facts).to_state,
779 State::FailedRetryable,
780 "{kind:?}"
781 );
782 }
783 }
784
785 #[test]
786 fn an_import_item_suotar_never_answered_is_uncertain() {
787 assert_eq!(
788 unanswered_item_outcome(
789 RegistryOperation::ImportAttainments,
790 State::Submitting,
791 &facts()
792 )
793 .to_state,
794 State::SubmissionUncertain
795 );
796 }
797
798 #[test]
799 fn an_unanswered_verify_item_just_polls_again() {
800 let outcome = unanswered_item_outcome(
801 RegistryOperation::VerifyAttainments,
802 State::AwaitingVerification,
803 &facts(),
804 );
805 assert_eq!(outcome.to_state, State::AwaitingVerification);
806 assert!(matches!(outcome.next, NextAttempt::After(_)));
807 }
808
809 #[test]
810 fn verify_never_fails_a_row() {
811 let facts = RowFacts {
812 submitted_at: Some(Utc::now() - chrono::Duration::days(30)),
813 ..facts()
814 };
815 for code in CreditRegistrationErrorCode::ALL {
816 let outcome = verify_error_outcome(State::AwaitingVerification, code, &facts);
817 assert!(
818 !matches!(
819 outcome.to_state,
820 State::FailedPermanent | State::FailedRetryable
821 ),
822 "{code:?}"
823 );
824 }
825 }
826
827 #[test]
828 fn a_reversal_in_sisu_needs_a_human() {
829 let outcome =
830 verify_error_outcome(State::AwaitingVerification, Code::Misregistered, &facts());
831 assert_eq!(outcome.to_state, State::Misregistered);
832 assert_eq!(outcome.needs_admin_attention, Some(AdminAttention::Raise));
833 }
834
835 #[test]
836 fn an_expired_verify_window_slows_down_and_asks_for_a_human() {
837 let facts = RowFacts {
838 submitted_at: Some(Utc::now() - chrono::Duration::days(20)),
839 verify_attempt_count: 40,
840 ..facts()
841 };
842 let outcome = verify_inconclusive_outcome(State::AwaitingVerification, &facts);
843 assert_eq!(outcome.to_state, State::AwaitingVerification);
844 assert_eq!(outcome.needs_admin_attention, Some(AdminAttention::Raise));
845 assert_eq!(outcome.next, NextAttempt::After(VERIFY_GIVE_UP_POLL));
846 }
847
848 #[test]
849 fn only_a_partially_registered_row_waits_for_the_course_unit_slot() {
850 let slot = Utc::now() + chrono::Duration::hours(14);
851 let facts = RowFacts {
852 submitted_at: Some(Utc::now() - chrono::Duration::hours(3)),
853 course_unit_poll_at: Some(slot),
854 ..facts()
855 };
856 let partial = verify_partial_outcome(&facts, facts.now);
857 assert_eq!(partial.next, NextAttempt::At(slot));
858 assert_eq!(
859 verify_inconclusive_outcome(State::PartiallyRegistered, &facts).next,
860 NextAttempt::At(slot)
861 );
862 assert_eq!(
863 verify_inconclusive_outcome(State::AwaitingVerification, &facts).next,
864 NextAttempt::After(VERIFY_WINDOW_INTERVAL)
865 );
866 assert_eq!(
867 verify_poll_lease_until(
868 State::PartiallyRegistered,
869 &facts,
870 chrono::Duration::minutes(1)
871 ),
872 slot
873 );
874 }
875
876 #[test]
879 fn a_row_with_nothing_to_submit_for_ages_out_of_the_retry_window() {
880 let fresh = missing_context(&facts()).outcome;
881 assert_eq!(fresh.to_state, State::FailedRetryable);
882 assert!(fresh.increment_submit_retry_count);
883
884 let old = missing_context(&RowFacts {
885 first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
886 ..facts()
887 })
888 .outcome;
889 assert_eq!(old.to_state, State::FailedPermanent);
890 assert_eq!(old.error_code, Some(Code::RetryWindowExpired));
891 }
892
893 #[test]
894 fn an_uncertain_row_backs_off_and_asks_for_a_human_only_after_a_day() {
895 let first = uncertain_recheck_outcome(&RowFacts {
896 verify_attempt_count: 1,
897 submitted_at: Some(Utc::now() - chrono::Duration::hours(1)),
898 ..facts()
899 });
900 assert_eq!(first.needs_admin_attention, None);
901 assert_eq!(first.to_state, State::SubmissionUncertain);
902 assert_eq!(first.next, NextAttempt::After(UNCERTAIN_RECHECK * 2));
903
904 let late = uncertain_recheck_outcome(&RowFacts {
905 verify_attempt_count: 30,
906 submitted_at: Some(Utc::now() - chrono::Duration::hours(25)),
907 ..facts()
908 });
909 assert_eq!(late.needs_admin_attention, Some(AdminAttention::Raise));
910 assert_eq!(late.next, NextAttempt::After(UNCERTAIN_MAX_RECHECK));
911 }
912}