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    /// The row's next course unit poll if it is or becomes partially registered, from
36    /// [`super::course_unit_poll_slots`]. Only verify's lease looks it up; `None` elsewhere.
37    pub course_unit_poll_at: Option<DateTime<Utc>>,
38}
39
40impl RowFacts {
41    /// The facts of `row` as they stand at `now`.
42    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/// When the pipeline may claim the row again.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum NextAttempt {
59    /// The target state's default cadence; see [`Transition::next_attempt_at`].
60    StateDefault,
61    /// After a backoff, which gets the jitter that spreads a batch that failed together.
62    After(TimeDelta),
63    At(DateTime<Utc>),
64    /// When the row's enrolment check schedule says, which only a row entering
65    /// `no_usable_enrolment` has; see [`super::enrolment_checks::schedule_next_check`].
66    NextEnrolmentRung,
67}
68
69/// What the phase writes for this row.
70#[derive(Debug, Clone, PartialEq)]
71pub struct Outcome {
72    pub to_state: CreditRegistrationState,
73    pub error_code: Option<CreditRegistrationErrorCode>,
74    /// `None` leaves the flag as it was, so a retry keeps an operator's earlier verdict.
75    pub needs_admin_attention: Option<AdminAttention>,
76    pub next: NextAttempt,
77    /// Set when Suotar says the stored number names nobody, so it is wrong wherever we hold it.
78    pub drop_verified_student_number: bool,
79    pub increment_submit_retry_count: bool,
80    /// A lookup that failed in transit, or only found the Sisu person: it was no check, so the row's
81    /// last check time stands.
82    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    /// Whether the row counts against the iteration's `items_failed`: an error code that is no
99    /// waiting answer, so a verify poll that is still waiting is not one. Not the same question as
100    /// [`CreditRegistrationState::is_failed_state`].
101    pub fn is_failure(&self) -> bool {
102        self.error_code.is_some_and(|code| !is_waiting_error(code))
103    }
104
105    /// Whether the row only awaits something outside the pipeline, such as the student's
106    /// enrolment: counted apart from failures.
107    pub fn is_waiting(&self) -> bool {
108        self.error_code.is_some_and(is_waiting_error)
109    }
110
111    /// The ledger write this outcome asks for, without the audit fields of the exchange behind it.
112    /// `expected_from_state` is as [`Transition::expected_from_state`]; a backoff counts from `now`.
113    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
154/// The one state whose only way out is `verify`; every path that cannot prove nothing was created
155/// ends here.
156pub fn submission_uncertain() -> Outcome {
157    Outcome::to(CreditRegistrationState::SubmissionUncertain)
158        .with_code(CreditRegistrationErrorCode::SisuTimeout)
159        .after(UNCERTAIN_RECHECK)
160}
161
162/// A per-item error on the calls leading towards a submission. Only `import` creates attainments,
163/// so anything uncertain there is verify-only, while the same code on `resolve-enrolments` retries.
164pub fn submit_error_outcome(
165    operation: RegistryOperation,
166    code: CreditRegistrationErrorCode,
167    facts: &RowFacts,
168) -> Outcome {
169    use CreditRegistrationErrorCode as Code;
170    // An unclassifiable answer is no evidence that nothing was created, and an admin retry from
171    // `failed_permanent` would then be a second submission.
172    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            // Dropping the number puts the student back in the linking flow, the only thing that
182            // can fix this, and the row heals itself once they link a working one.
183            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
200/// A per-item error while polling `verify`. Never a failure: the attainment may exist, and a row
201/// marked failed invites a second submission later. `notRegistered` is not an error here; see
202/// [`verify_not_registered_outcome`].
203pub 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
216/// A `verify` poll with no usable answer: nothing came back, or nothing we act on.
217pub 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
226/// The row stays in `state` until its next verify poll: the next course unit slot once only the
227/// course unit attainment is missing, else timed from the submission.
228fn 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
238/// The next course unit slot, only for a row that has nothing left to verify but the course unit.
239fn 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
245/// A `verify` poll that found only the assessment item attainment. The submission landed, so an
246/// uncertain row stops being uncertain, but the row is not registered until the course unit
247/// attainment appears. `partially_registered_at` is when a poll first saw this.
248pub 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
257/// A `verify` poll answered `notRegistered`: Suotar has no trace of the submission, so the row is
258/// new work again and goes back through resolve-enrolments and import. `reimport_count` counts
259/// this resend too.
260pub 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
274/// A fruitless look through `existingAttainments` for an attainment we may have created. Once the
275/// attainment has had a day to show up a human checks Sisu by hand; the row still never resubmits.
276pub 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
286/// The outcome for every row of a batch Suotar rejected as a whole. On `import` all that matters is
287/// whether the request could have been acted on: a connection that never opened proves it was not.
288/// A waiting row's lookup keeps waiting only through an outage; a refusal of the request itself
289/// would come back every retry, so it ages out like any other failure.
290pub 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
306/// A lookup for a row waiting for an enrolment that failed in transit: the row keeps waiting and
307/// retries the same check shortly, with none of a failure's retry window or count, which over a
308/// schedule of months would expire it. `None` for any other row or operation.
309fn 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
321/// A row Suotar refused as a malformed request even in a batch of its own: resending the same
322/// request is refused the same way, so it needs a human.
323pub fn isolated_malformed_request_outcome() -> Outcome {
324    Outcome::to(CreditRegistrationState::FailedPermanent)
325        .with_code(CreditRegistrationErrorCode::MalformedRequest)
326        .needing_admin()
327}
328
329/// An item we sent and Suotar did not answer. On `import` that leaves us where a timeout does;
330/// elsewhere the call simply did not happen for that row.
331pub 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
350/// The ledger error code for a request the study registry rejected, or never answered, as a whole.
351pub 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        // A bare 5xx may not be Suotar's unavailability, but a retry is all either one gets.
359        RegistryErrorKind::TemporarilyUnavailable | RegistryErrorKind::ServerError => {
360            CreditRegistrationErrorCode::ServiceTemporarilyUnavailable
361        }
362        RegistryErrorKind::NotDelivered | RegistryErrorKind::NoAnswer => {
363            CreditRegistrationErrorCode::TransportError
364        }
365    }
366}
367
368/// Whether a failure could go away on its own.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum Failure {
371    Transient,
372    /// The same request would fail the same way on every retry.
373    Lasting,
374}
375
376/// Retryable until the row has been failing for a week, and then a support case rather than an
377/// endless one. A transient failure of a waiting row's lookup is not counted as one at all; see
378/// [`waiting_lookup_failed`].
379fn 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
408/// What an `import` answer that settled the row does to it, including the wait Sisu needs before
409/// the first verify poll can find anything.
410pub 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
418/// How long a verify poll pushes a row in `state` out of reach while its request is out, so a
419/// concurrent iteration cannot poll it twice: until its next poll would be due anyway. The poll's
420/// own outcome overwrites this. `calls` is the longest the iteration's registry calls may take
421/// together: the poll and the recovery lookup after it.
422pub 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/// A move a phase makes on a claimed row without an answer to decide from: the outcome, and the
438/// timeline line that explains it.
439#[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    /// The ledger write, guarded and timed as [`Outcome::transition`].
461    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
473/// The timeline line for a row waiting on a student number, whichever phase found it missing.
474pub const NO_VERIFIED_STUDENT_NUMBER_MESSAGE: &str =
475    "No verified student number is linked to the account.";
476
477/// Committed before the import request leaves: a row found in `submitting` after a restart has an
478/// unknown outcome and is never sent again.
479pub fn submitting() -> UnaskedMove {
480    UnaskedMove::silent(Outcome::to(CreditRegistrationState::Submitting))
481}
482
483/// Import found the completion already registered by another registrar.
484pub 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
491/// The frozen payload lacks a field, so the enrolment is resolved again.
492pub 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
499/// The frozen grade is not one Sisu accepts.
500pub 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
509/// `field` of the frozen payload is one Sisu would refuse, and the whole batch with it.
510pub 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
519/// An import row a split still held unsent when a shutdown or an error stopped the split.
520pub 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
528/// No verified student number to resolve the enrolment with.
529pub fn no_verified_student_number() -> UnaskedMove {
530    UnaskedMove::new(
531        Outcome::to(CreditRegistrationState::Pending),
532        NO_VERIFIED_STUDENT_NUMBER_MESSAGE,
533    )
534}
535
536/// The module lacks what the enrolment lookup needs; `code` says what.
537pub 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
546/// Holds a first resolve out of `import`'s claim while its lookup is out.
547pub fn resolving_enrolment() -> UnaskedMove {
548    UnaskedMove::silent(Outcome::to(CreditRegistrationState::ResolvingEnrolment))
549}
550
551/// A row `resolve-enrolments` claimed but has no completion or module to ask about. Retryable like
552/// any other failure, and it accrues retry age, so a row whose context never comes back expires
553/// instead of being repolled forever.
554pub 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    /// Pins each code to the exact state import() routes it to, so a future edit that misroutes one
609    /// code fails here even though the match stays exhaustive to the compiler.
610    #[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    /// Each of these is a refusal of the item before Sisu saw it, so nothing was created. Adding to
648    /// the list is a decision about a real transcript.
649    #[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    /// An admin retry from `failed_permanent` would send an import whose outcome nobody knows.
669    #[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    /// A row the phase can build no request for has to accrue retry age like any other failure, or
877    /// nothing ever stops it being claimed again.
878    #[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}