Skip to main content

headless_lms_models/library/credit_registration/
outcomes.rs

1//! Every move the pipeline makes on one ledger row, decided apart from the phases that apply it:
2//! what an answer from the study registry does to its row, and the moves a phase makes without
3//! asking. No outcome here may return a state that leads back to `import`: a row whose import may
4//! have landed must never be sent again. The one exception is verify's `notRegistered`, which is
5//! the registry itself saying the submission did not land.
6
7use 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/// The row's scheduling history, which is all these decisions need from it.
24#[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    /// The row is waiting for an enrolment, so a lookup that fails in transit leaves it waiting.
32    pub is_waiting_for_enrolment: bool,
33    /// The code the row carries now, which a waiting row keeps through a failed lookup.
34    pub error_code: Option<CreditRegistrationErrorCode>,
35}
36
37impl RowFacts {
38    /// The facts of `row` as they stand at `now`.
39    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/// When the pipeline may claim the row again.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub enum NextAttempt {
55    /// The target state's default cadence; see [`Transition::next_attempt_at`].
56    StateDefault,
57    /// After a backoff, which gets the jitter that spreads a batch that failed together.
58    After(TimeDelta),
59    At(DateTime<Utc>),
60    /// When the row's enrolment check schedule says, which only a row entering
61    /// `no_usable_enrolment` has; see [`super::enrolment_checks::schedule_next_check`].
62    NextEnrolmentRung,
63}
64
65/// What the phase writes for this row.
66#[derive(Debug, Clone, PartialEq)]
67pub struct Outcome {
68    pub to_state: CreditRegistrationState,
69    pub error_code: Option<CreditRegistrationErrorCode>,
70    /// `None` leaves the flag as it was, so a retry keeps an operator's earlier verdict.
71    pub needs_admin_attention: Option<AdminAttention>,
72    pub next: NextAttempt,
73    /// Set when Suotar says the stored number names nobody, so it is wrong wherever we hold it.
74    pub drop_verified_student_number: bool,
75    pub increment_submit_retry_count: bool,
76    /// A lookup that failed in transit, or only found the Sisu person: it was no check, so the row's
77    /// last check time stands.
78    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    /// Whether the row counts against the iteration's `items_failed`: an error code that is no
95    /// waiting answer, so a verify poll that is still waiting is not one. Not the same question as
96    /// [`CreditRegistrationState::is_failed_state`].
97    pub fn is_failure(&self) -> bool {
98        self.error_code.is_some_and(|code| !is_waiting_error(code))
99    }
100
101    /// Whether the row only awaits something outside the pipeline, such as the student's
102    /// enrolment: counted apart from failures.
103    pub fn is_waiting(&self) -> bool {
104        self.error_code.is_some_and(is_waiting_error)
105    }
106
107    /// The ledger write this outcome asks for, without the audit fields of the exchange behind it.
108    /// `expected_from_state` is as [`Transition::expected_from_state`]; a backoff counts from `now`.
109    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
150/// The one state whose only way out is `verify`; every path that cannot prove nothing was created
151/// ends here.
152pub fn submission_uncertain() -> Outcome {
153    Outcome::to(CreditRegistrationState::SubmissionUncertain)
154        .with_code(CreditRegistrationErrorCode::SisuTimeout)
155        .after(UNCERTAIN_RECHECK)
156}
157
158/// A per-item error on the calls leading towards a submission. Only `import` creates attainments,
159/// so anything uncertain there is verify-only, while the same code on `resolve-enrolments` retries.
160pub fn submit_error_outcome(
161    operation: RegistryOperation,
162    code: CreditRegistrationErrorCode,
163    facts: &RowFacts,
164) -> Outcome {
165    use CreditRegistrationErrorCode as Code;
166    // An unclassifiable answer is no evidence that nothing was created, and an admin retry from
167    // `failed_permanent` would then be a second submission.
168    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            // Dropping the number puts the student back in the linking flow, the only thing that
178            // can fix this, and the row heals itself once they link a working one.
179            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
196/// A per-item error while polling `verify`. Never a failure: the attainment may exist, and a row
197/// marked failed invites a second submission later. `notRegistered` is not an error here; see
198/// [`verify_not_registered_outcome`].
199pub 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
212/// A `verify` poll with no usable answer: nothing came back, or nothing we act on.
213pub 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
227/// A `verify` poll that found only the assessment item attainment. The submission landed, so an
228/// uncertain row stops being uncertain, but the row is not registered until the course unit
229/// attainment appears. `partially_registered_at` is when a poll first saw this.
230pub 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
240/// A `verify` poll answered `notRegistered`: Suotar has no trace of the submission, so the row is
241/// new work again and goes back through resolve-enrolments and import. `reimport_count` counts
242/// this resend too.
243pub 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
257/// A fruitless look through `existingAttainments` for an attainment we may have created. Once the
258/// attainment has had a day to show up a human checks Sisu by hand; the row still never resubmits.
259pub 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
269/// The outcome for every row of a batch Suotar rejected as a whole. On `import` all that matters is
270/// whether the request could have been acted on: a connection that never opened proves it was not.
271/// A waiting row's lookup keeps waiting only through an outage; a refusal of the request itself
272/// would come back every retry, so it ages out like any other failure.
273pub 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
289/// A lookup for a row waiting for an enrolment that failed in transit: the row keeps waiting and
290/// retries the same check shortly, with none of a failure's retry window or count, which over a
291/// schedule of months would expire it. `None` for any other row or operation.
292fn 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
304/// A row Suotar refused as a malformed request even in a batch of its own: resending the same
305/// request is refused the same way, so it needs a human.
306pub fn isolated_malformed_request_outcome() -> Outcome {
307    Outcome::to(CreditRegistrationState::FailedPermanent)
308        .with_code(CreditRegistrationErrorCode::MalformedRequest)
309        .needing_admin()
310}
311
312/// An item we sent and Suotar did not answer. On `import` that leaves us where a timeout does;
313/// elsewhere the call simply did not happen for that row.
314pub 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
333/// The ledger error code for a request the study registry rejected, or never answered, as a whole.
334pub 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        // A bare 5xx may not be Suotar's unavailability, but a retry is all either one gets.
342        RegistryErrorKind::TemporarilyUnavailable | RegistryErrorKind::ServerError => {
343            CreditRegistrationErrorCode::ServiceTemporarilyUnavailable
344        }
345        RegistryErrorKind::NotDelivered | RegistryErrorKind::NoAnswer => {
346            CreditRegistrationErrorCode::TransportError
347        }
348    }
349}
350
351/// Whether a failure could go away on its own.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum Failure {
354    Transient,
355    /// The same request would fail the same way on every retry.
356    Lasting,
357}
358
359/// Retryable until the row has been failing for a week, and then a support case rather than an
360/// endless one. A transient failure of a waiting row's lookup is not counted as one at all; see
361/// [`waiting_lookup_failed`].
362fn 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
391/// What an `import` answer that settled the row does to it, including the wait Sisu needs before
392/// the first verify poll can find anything.
393pub 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
401/// How long a verify poll pushes the row out of reach while its request is out, so a concurrent
402/// iteration cannot poll it twice. The poll's own outcome overwrites this. `calls` is the longest
403/// the iteration's registry calls may take together: the poll and the recovery lookup after it.
404pub 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/// A move a phase makes on a claimed row without an answer to decide from: the outcome, and the
416/// timeline line that explains it.
417#[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    /// The ledger write, guarded and timed as [`Outcome::transition`].
439    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
451/// The timeline line for a row waiting on a student number, whichever phase found it missing.
452pub const NO_VERIFIED_STUDENT_NUMBER_MESSAGE: &str =
453    "No verified student number is linked to the account.";
454
455/// Committed before the import request leaves: a row found in `submitting` after a restart has an
456/// unknown outcome and is never sent again.
457pub fn submitting() -> UnaskedMove {
458    UnaskedMove::silent(Outcome::to(CreditRegistrationState::Submitting))
459}
460
461/// Import found the completion already registered by another registrar.
462pub 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
469/// The frozen payload lacks a field, so the enrolment is resolved again.
470pub 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
477/// The frozen grade is not one Sisu accepts.
478pub 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
487/// `field` of the frozen payload is one Sisu would refuse, and the whole batch with it.
488pub 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
497/// An import row a split still held unsent when a shutdown or an error stopped the split.
498pub 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
506/// No verified student number to resolve the enrolment with.
507pub fn no_verified_student_number() -> UnaskedMove {
508    UnaskedMove::new(
509        Outcome::to(CreditRegistrationState::Pending),
510        NO_VERIFIED_STUDENT_NUMBER_MESSAGE,
511    )
512}
513
514/// The module lacks what the enrolment lookup needs; `code` says what.
515pub 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
524/// Holds a first resolve out of `import`'s claim while its lookup is out.
525pub fn resolving_enrolment() -> UnaskedMove {
526    UnaskedMove::silent(Outcome::to(CreditRegistrationState::ResolvingEnrolment))
527}
528
529/// A row `resolve-enrolments` claimed but has no completion or module to ask about. Retryable like
530/// any other failure, and it accrues retry age, so a row whose context never comes back expires
531/// instead of being repolled forever.
532pub 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    /// Pins each code to the exact state import() routes it to, so a future edit that misroutes one
586    /// code fails here even though the match stays exhaustive to the compiler.
587    #[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    /// Each of these is a refusal of the item before Sisu saw it, so nothing was created. Adding to
625    /// the list is a decision about a real transcript.
626    #[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    /// An admin retry from `failed_permanent` would send an import whose outcome nobody knows.
646    #[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    /// A row the phase can build no request for has to accrue retry age like any other failure, or
826    /// nothing ever stops it being claimed again.
827    #[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}