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