Skip to main content

headless_lms_models/library/credit_registration/
preconditions.rs

1//! Moving a row along, or out of, the chain of things that must be true before we submit. Decided
2//! from the database alone, so it keeps running during a Suotar outage.
3
4use crate::credit_registrations::{
5    BatchMove, CreditRegistrationErrorCode, CreditRegistrationState, RegistrationScope, Transition,
6    transition_batch,
7};
8use crate::prelude::*;
9use chrono::TimeDelta;
10
11use super::backoff::{
12    self, RESOLVING_RECOVERY_GRACE, SUBMIT_MAX_RETRY_AGE, SUBMITTING_RECOVERY_GRACE, verify_delay,
13};
14use super::enrolment_check_schedule::{
15    EnrolmentCheckGroup, EnrolmentCheckSource, ScheduledEnrolmentCheck, first_check,
16};
17use super::enrolment_checks::{EnrolmentCheckStart, record_starts};
18use super::outcomes::{NO_VERIFIED_STUDENT_NUMBER_MESSAGE, Outcome, UnaskedMove};
19use super::pending_reason::{CreditRegistrationPendingReason, PendingPreconditions};
20
21/// How many rows one iteration may move.
22pub const PRECONDITIONS_LIMIT: i64 = 500;
23
24#[derive(Debug, Clone, PartialEq)]
25struct PendingMove {
26    id: Uuid,
27    state: CreditRegistrationState,
28    next_attempt_at: DateTime<Utc>,
29    state_entered_at: DateTime<Utc>,
30    submitted_at: Option<DateTime<Utc>>,
31    first_failed_at: Option<DateTime<Utc>>,
32    completion_deleted: bool,
33    /// Names the blocker in the audit event when the target is `pending`.
34    preconditions: PendingPreconditions,
35    has_submitted_attainment: bool,
36    has_payload_snapshot: bool,
37    frozen_identity_stale: bool,
38    payload_unweighed_against_held_credit: bool,
39    /// Set for a row that would leave `pending` or `blocked` for its first enrolment check: it
40    /// starts the check schedule instead of resolving at once.
41    check_start: Option<CheckStartFacts>,
42}
43
44/// What the preconditions make of one row.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum Target {
47    Stay,
48    Move(CreditRegistrationState),
49    /// A retryable row whose backoff has elapsed, resuming where [`resume_state`] says.
50    Resume,
51}
52
53/// The one table of the pipeline's moves that need no study registry: recovering rows a dead
54/// worker left behind, and moving a row along, or out of, the chain of preconditions. A target equal
55/// to the row's state is staying put.
56fn precondition_target(row: &PendingMove, now: DateTime<Utc>) -> Target {
57    use CreditRegistrationState as State;
58    let facts = &row.preconditions;
59    let elapsed = |since: DateTime<Utc>, span: TimeDelta| since < now - span;
60    match row.state {
61        // A worker committed `submitting` and never came back with an answer. There is no way to
62        // know whether the request landed, so the row is never imported again. Timed from the last
63        // send, which a split import batch repeats.
64        State::Submitting
65            if elapsed(
66                row.submitted_at.unwrap_or(row.state_entered_at),
67                SUBMITTING_RECOVERY_GRACE,
68            ) =>
69        {
70            Target::Move(State::SubmissionUncertain)
71        }
72        State::Submitting
73        | State::SubmissionUncertain
74        | State::AwaitingVerification
75        | State::PartiallyRegistered => Target::Stay,
76        _ if row.completion_deleted => Target::Move(State::Cancelled),
77        State::FailedRetryable if !facts.completion_eligible => Target::Move(State::Blocked),
78        State::FailedRetryable
79            if row
80                .first_failed_at
81                .is_some_and(|first_failed_at| elapsed(first_failed_at, SUBMIT_MAX_RETRY_AGE)) =>
82        {
83            Target::Move(State::FailedPermanent)
84        }
85        // Before the resume below, or a retry would carry on past a precondition the student has
86        // since removed and import would send the frozen student_number under a link they gave up.
87        State::FailedRetryable if !facts.has_verified_student_number => {
88            Target::Move(State::Pending)
89        }
90        // Only a row with nothing in flight: one with a submission to verify has to resume there.
91        State::FailedRetryable if !facts.course_code_allowed && !row.has_submitted_attainment => {
92            Target::Move(State::Pending)
93        }
94        State::FailedRetryable if row.next_attempt_at <= now => Target::Resume,
95        State::FailedRetryable => Target::Stay,
96        // Eligibility lost after the row had already moved on is what `blocked` is for; a row still
97        // waiting is simply where it belongs, and the reason it reports changes to say so.
98        state if !facts.completion_eligible && state != State::Pending => {
99            Target::Move(State::Blocked)
100        }
101        _ if !facts.completion_eligible
102            || !facts.has_verified_student_number
103            || !facts.course_code_allowed =>
104        {
105            Target::Move(State::Pending)
106        }
107        // resolve-enrolments checks it where it stands when its schedule says.
108        State::NoUsableEnrolment => Target::Stay,
109        // A relink after the payload was frozen must not let the row import against the account's
110        // previous number: send it back to resolve a fresh payload against the current one.
111        State::CheckingEnrolment if row.frozen_identity_stale => Target::Move(State::ReadyToSubmit),
112        // Already queued for import with its payload frozen; sending it back would resolve again
113        // forever.
114        State::CheckingEnrolment => Target::Stay,
115        // Past the grace the worker that claimed it is gone, and asking again is harmless.
116        State::ResolvingEnrolment if elapsed(row.state_entered_at, RESOLVING_RECOVERY_GRACE) => {
117            Target::Move(State::ReadyToSubmit)
118        }
119        // A resolve-enrolments call for this row is in flight; only that phase's own commit may
120        // move it, or import could claim it before the enrolment is actually resolved.
121        State::ResolvingEnrolment => Target::Stay,
122        _ => Target::Move(State::ReadyToSubmit),
123    }
124}
125
126#[derive(Debug, Clone, PartialEq)]
127struct CheckStartFacts {
128    group: EnrolmentCheckGroup,
129    anchor_at: DateTime<Utc>,
130    source: EnrolmentCheckSource,
131}
132
133/// Where a `failed_retryable` row goes when its backoff elapses, derived from how far it had got.
134/// Never `submitting`: only the import phase writes that, in the transaction before it sends.
135///
136/// `frozen_identity_stale` demotes a frozen payload to no payload at all. Only a `notRegistered`
137/// resend clears `selected_enrolment_id`/`grade_id`, so a row sent back to re-resolve after a
138/// relink still looks frozen; without this it would resume at `checking_enrolment` and import the
139/// previous number.
140///
141/// `payload_unweighed_against_held_credit` also resolves again: another row of the module holds a
142/// credit this row is not marked to replace, and only resolve-enrolments may weigh the two and hand
143/// this row that row's slot in `uq_credit_registrations_person_module`. Import would otherwise hold
144/// the row back for good.
145fn resume_state(
146    has_submitted_attainment_id: bool,
147    has_payload_snapshot: bool,
148    frozen_identity_stale: bool,
149    payload_unweighed_against_held_credit: bool,
150) -> CreditRegistrationState {
151    if payload_unweighed_against_held_credit {
152        CreditRegistrationState::ReadyToSubmit
153    } else if has_submitted_attainment_id {
154        CreditRegistrationState::AwaitingVerification
155    } else if has_payload_snapshot && !frozen_identity_stale {
156        CreditRegistrationState::CheckingEnrolment
157    } else {
158        CreditRegistrationState::ReadyToSubmit
159    }
160}
161
162/// Applies at most `limit` moves to the scoped rows and returns how many moved.
163///
164/// A row a worker claimed between the snapshot and the write is left where it is: its state is that
165/// phase's to own now, and the next iteration decides again from whatever it committed. Writing
166/// anyway could put an in-flight import back into a state a second import claims.
167///
168/// A row that would start resolving without ever having been checked starts its enrolment check
169/// schedule instead: nobody has enrolled at the moment they complete, so it waits in
170/// `no_usable_enrolment` for the first rung of its group's ladder unless that rung is due already.
171/// A row waiting there is left to resolve-enrolments, which checks it where it stands.
172pub async fn recompute_preconditions(
173    conn: &mut PgConnection,
174    scope: &RegistrationScope,
175    limit: i64,
176) -> ModelResult<i64> {
177    let now = Utc::now();
178    let mut tx = conn.begin().await?;
179    let mut starts = Vec::new();
180    let moves: Vec<BatchMove> = pending_moves(&mut tx, scope, limit)
181        .await?
182        .iter()
183        .filter_map(|pending| {
184            let mut target = match precondition_target(pending, now) {
185                Target::Stay => pending.state,
186                Target::Move(target) => target,
187                Target::Resume => resume_state(
188                    pending.has_submitted_attainment,
189                    pending.has_payload_snapshot,
190                    pending.frozen_identity_stale,
191                    pending.payload_unweighed_against_held_credit,
192                ),
193            };
194            let mut next_attempt_at = None;
195            if target == CreditRegistrationState::AwaitingVerification {
196                next_attempt_at = Some(backoff::next_attempt_at(
197                    now,
198                    verify_delay(pending.submitted_at, now),
199                ));
200            }
201            if target == CreditRegistrationState::ReadyToSubmit
202                && let Some(start) = &pending.check_start
203                && let Some(scheduled) = first_check(start.group, start.anchor_at)
204            {
205                // A rung already past when the schedule starts is due from now, or its lateness
206                // would count time before the row could be checked.
207                let scheduled = ScheduledEnrolmentCheck {
208                    due_at: scheduled.due_at.max(now),
209                    ..scheduled
210                };
211                starts.push(EnrolmentCheckStart {
212                    credit_registration_id: pending.id,
213                    group: start.group,
214                    anchor_at: start.anchor_at,
215                    scheduled,
216                    source: start.source,
217                });
218                if scheduled.due_at > now {
219                    target = CreditRegistrationState::NoUsableEnrolment;
220                    next_attempt_at = Some(scheduled.release_at());
221                }
222            }
223            (target != pending.state).then(|| BatchMove {
224                id: pending.id,
225                transition: Transition {
226                    next_attempt_at,
227                    ..precondition_move(pending, target).transition(
228                        // `pending_moves` reads without a row lock, so the guard refuses a stale
229                        // write instead of overwriting a row another phase already moved.
230                        Some(pending.state),
231                        now,
232                    )
233                },
234            })
235        })
236        .collect();
237    let moved = transition_batch(&mut tx, &moves).await?;
238    record_starts(&mut tx, &starts).await?;
239    tx.commit().await?;
240    Ok(moved)
241}
242
243/// The move each edge makes: kept out of the query so every edge's error code, admin flag and
244/// timeline line sit in one place.
245fn precondition_move(pending: &PendingMove, target: CreditRegistrationState) -> UnaskedMove {
246    use CreditRegistrationState as State;
247    let to = Outcome::to(target);
248    match target {
249        State::SubmissionUncertain => UnaskedMove::new(
250            to.with_code(CreditRegistrationErrorCode::SisuTimeout),
251            "Found still submitting after a restart, so the import may or may not have been \
252             processed. Only verification may touch it from here.",
253        ),
254        State::Cancelled => UnaskedMove::new(
255            to,
256            "The completion no longer exists and nothing had been submitted.",
257        ),
258        State::Blocked => {
259            UnaskedMove::new(to, "The completion is no longer eligible for registration.")
260        }
261        State::FailedPermanent => UnaskedMove::new(
262            to.with_code(CreditRegistrationErrorCode::RetryWindowExpired)
263                .needing_admin(),
264            "Retried for a week without success.",
265        ),
266        // The ledger does not record which precondition a `pending` row waits on, so the event is
267        // where the answer is kept for whoever reads the timeline later.
268        State::Pending => UnaskedMove {
269            outcome: to,
270            message: pending.preconditions.reason().map(|reason| {
271                match reason {
272                    CreditRegistrationPendingReason::Completion => {
273                        "The completion is not registrable yet."
274                    }
275                    CreditRegistrationPendingReason::StudentNumber => {
276                        NO_VERIFIED_STUDENT_NUMBER_MESSAGE
277                    }
278                    CreditRegistrationPendingReason::CourseCode => {
279                        "Suotar does not accept the module's course code, so nothing is sent \
280                         until it does."
281                    }
282                }
283                .to_string()
284            }),
285        },
286        State::NoUsableEnrolment => UnaskedMove::new(to, "Waiting for the first enrolment check."),
287        // Keys off `pending.state`, not just `target`: the message is about where the row came
288        // from, unlike every arm above.
289        State::ReadyToSubmit if pending.state == State::CheckingEnrolment => UnaskedMove::new(
290            to,
291            "The linked student number changed after this row's payload was frozen, so the \
292             enrolment is resolved again against the current one.",
293        ),
294        _ => UnaskedMove::silent(to),
295    }
296}
297
298/// The facts of the rows that may need a move, so `limit` cannot be spent on rows that need nothing.
299///
300/// The `WHERE` only narrows to the rows [`precondition_target`] could move, and must keep every one
301/// of them: the decision itself is that function's.
302async fn pending_moves(
303    conn: &mut PgConnection,
304    scope: &RegistrationScope,
305    limit: i64,
306) -> ModelResult<Vec<PendingMove>> {
307    let rows = sqlx::query!(
308        r#"
309WITH facts AS (
310  SELECT cr.id,
311    cr.state,
312    cr.next_attempt_at,
313    cr.state_entered_at,
314    cr.submitted_at,
315    cr.first_failed_at,
316    cr.submitted_attainment_id IS NOT NULL AS has_submitted_attainment,
317    (
318      cr.selected_enrolment_id IS NOT NULL
319      AND cr.grade_id IS NOT NULL
320    ) AS has_payload_snapshot,
321    p.completion_deleted,
322    p.completion_eligible AS eligible,
323    p.has_verified_student_number AS has_student_number,
324    p.course_code_allowed,
325    p.frozen_identity_stale,
326    EXISTS (
327      SELECT 1
328      FROM credit_registrations held
329      WHERE held.user_id = cr.user_id
330        AND held.course_module_id = cr.course_module_id
331        AND held.id <> cr.id
332        AND held.deleted_at IS NULL
333        AND held.superseded_by_id IS NULL
334        AND held.pending_superseded_by_id IS DISTINCT FROM cr.id
335        AND held.state = ANY($8::credit_registration_state [])
336    ) AS payload_unweighed_against_held_credit,
337    cr.state IN ('pending', 'blocked')
338    AND cr.enrolment_checked_at IS NULL AS starts_enrolment_checks,
339    GREATEST(
340      cr.enrolment_check_group,
341      CASE
342        WHEN sig.last_check_requested_at IS NOT NULL THEN 'check_requested'
343        WHEN sig.last_visited_at IS NOT NULL THEN 'visited'
344        ELSE 'completed'
345      END::enrolment_check_group,
346      (
347        SELECT MAX(earlier.enrolment_check_group)
348        FROM credit_registrations earlier
349        WHERE earlier.user_id = cr.user_id
350          AND earlier.course_module_id = cr.course_module_id
351          AND earlier.created_at < cr.created_at
352          AND earlier.deleted_at IS NULL
353      )
354    ) AS check_group,
355    GREATEST(
356      cmc.completion_date,
357      vsn.verified_at,
358      COALESCE(sig.last_check_requested_at, sig.last_visited_at)
359    ) AS check_anchor_at,
360    CASE
361      WHEN sig.last_check_requested_at IS NOT NULL THEN sig.check_request_source
362      ELSE 'schedule'
363    END::enrolment_check_source AS check_source
364  FROM credit_registrations cr
365    JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
366    JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
367    LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
368    AND vsn.deleted_at IS NULL
369    LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
370    AND sig.deleted_at IS NULL
371    LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
372    AND conf.deleted_at IS NULL
373  WHERE cr.deleted_at IS NULL
374    AND cr.superseded_by_id IS NULL
375    AND cr.terminal_at IS NULL
376    -- Only a human moves a row the study registry reversed.
377    AND cr.state <> 'misregistered'
378    AND conf.paused_at IS NULL
379    AND ($2::uuid IS NULL OR cr.course_id = $2)
380    AND ($3::uuid IS NULL OR cr.user_id = $3)
381    AND (
382      cardinality($4::uuid []) = 0
383      OR cr.id = ANY($4::uuid [])
384    )
385)
386SELECT id,
387  state AS "state: CreditRegistrationState",
388  next_attempt_at,
389  state_entered_at,
390  submitted_at,
391  first_failed_at,
392  completion_deleted AS "completion_deleted!",
393  eligible AS "eligible!",
394  has_student_number AS "has_student_number!",
395  course_code_allowed AS "course_code_allowed!",
396  has_submitted_attainment AS "has_submitted_attainment!",
397  has_payload_snapshot AS "has_payload_snapshot!",
398  frozen_identity_stale AS "frozen_identity_stale!",
399  payload_unweighed_against_held_credit AS "payload_unweighed_against_held_credit!",
400  starts_enrolment_checks AS "starts_enrolment_checks!",
401  check_group AS "check_group!",
402  check_anchor_at AS "check_anchor_at!",
403  check_source AS "check_source!"
404FROM facts
405WHERE (
406    state = 'submitting'
407    AND COALESCE(submitted_at, state_entered_at) < now() - $5::interval
408  )
409  OR (
410    state <> ALL($9::credit_registration_state [])
411    AND (
412      completion_deleted
413      OR (
414        state = 'failed_retryable'
415        AND (
416          NOT eligible
417          OR NOT has_student_number
418          OR (
419            NOT course_code_allowed
420            AND NOT has_submitted_attainment
421          )
422          OR next_attempt_at <= now()
423          OR first_failed_at < now() - $6::interval
424        )
425      )
426      OR (
427        state NOT IN ('failed_retryable', 'pending', 'blocked')
428        AND (
429          NOT eligible
430          OR NOT has_student_number
431          OR NOT course_code_allowed
432        )
433      )
434      OR (
435        state = 'blocked'
436        AND eligible
437      )
438      OR (
439        state = 'pending'
440        AND eligible
441        AND has_student_number
442        AND course_code_allowed
443      )
444      OR (
445        state = 'checking_enrolment'
446        AND frozen_identity_stale
447      )
448      OR (
449        state = 'resolving_enrolment'
450        AND state_entered_at < now() - $7::interval
451      )
452    )
453  )
454ORDER BY state_entered_at
455LIMIT $1
456        "#,
457        limit,
458        scope.course_id,
459        scope.user_id,
460        &scope.credit_registration_ids,
461        SUBMITTING_RECOVERY_GRACE as TimeDelta,
462        SUBMIT_MAX_RETRY_AGE as TimeDelta,
463        RESOLVING_RECOVERY_GRACE as TimeDelta,
464        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
465        &CreditRegistrationState::IN_FLIGHT_STATES as &[CreditRegistrationState],
466    )
467    .fetch_all(conn)
468    .await?;
469    Ok(rows
470        .into_iter()
471        .map(|row| PendingMove {
472            id: row.id,
473            state: row.state,
474            next_attempt_at: row.next_attempt_at,
475            state_entered_at: row.state_entered_at,
476            submitted_at: row.submitted_at,
477            first_failed_at: row.first_failed_at,
478            completion_deleted: row.completion_deleted,
479            preconditions: PendingPreconditions {
480                completion_eligible: row.eligible,
481                has_verified_student_number: row.has_student_number,
482                course_code_allowed: row.course_code_allowed,
483            },
484            has_submitted_attainment: row.has_submitted_attainment,
485            has_payload_snapshot: row.has_payload_snapshot,
486            frozen_identity_stale: row.frozen_identity_stale,
487            payload_unweighed_against_held_credit: row.payload_unweighed_against_held_credit,
488            check_start: row.starts_enrolment_checks.then_some(CheckStartFacts {
489                group: row.check_group,
490                anchor_at: row.check_anchor_at,
491                source: row.check_source,
492            }),
493        })
494        .collect())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::course_module_completions::{
501        CourseModuleCompletionGranter, NewCourseModuleCompletion,
502    };
503    use crate::credit_registrations::{NewCreditRegistration, get_by_id, transition};
504    use crate::test_helper::*;
505    use crate::verified_student_numbers::{
506        NewVerifiedStudentNumber, StudentNumberVerificationMethod,
507    };
508
509    struct Fixture {
510        registration: Uuid,
511        completion: Uuid,
512    }
513
514    async fn fixture(
515        conn: &mut PgConnection,
516        user: Uuid,
517        course: Uuid,
518        instance: Uuid,
519        course_module: Uuid,
520    ) -> Fixture {
521        let completion = crate::course_module_completions::insert(
522            conn,
523            PKeyPolicy::Generate,
524            &NewCourseModuleCompletion {
525                course_id: course,
526                course_module_id: course_module,
527                user_id: user,
528                completion_date: Utc::now(),
529                completion_registration_attempt_date: None,
530                completion_language: "en".to_string(),
531                eligible_for_ects: true,
532                email: "student@example.com".to_string(),
533                grade: Some(4),
534                passed: true,
535            },
536            CourseModuleCompletionGranter::Automatic,
537        )
538        .await
539        .unwrap();
540        // Defaults to false, which the recompute reads as an unmet prerequisite.
541        crate::course_module_completions::update_prerequisite_modules_completed(
542            conn,
543            completion.id,
544            true,
545        )
546        .await
547        .unwrap();
548        let registration = crate::credit_registrations::insert(
549            conn,
550            PKeyPolicy::Generate,
551            &NewCreditRegistration {
552                course_module_completion_id: completion.id,
553                user_id: user,
554                course_id: course,
555                course_module_id: course_module,
556                course_instance_id: instance,
557                attempt_number: 1,
558            },
559            None,
560        )
561        .await
562        .unwrap();
563        Fixture {
564            registration,
565            completion: completion.id,
566        }
567    }
568
569    async fn link_student_number(conn: &mut PgConnection, user: Uuid) {
570        crate::verified_student_numbers::insert(
571            conn,
572            PKeyPolicy::Generate,
573            &NewVerifiedStudentNumber {
574                user_id: user,
575                student_number: DbSecret::new(format!("9{:08}", rand_suffix())),
576                sisu_person_id: DbSecret::new(format!("hy-hlo-{}", rand_suffix())),
577                first_names: None,
578                last_name: None,
579                verified_via: StudentNumberVerificationMethod::EmailedLink,
580                verified_via_email: Some(DbSecret::new("student@helsinki.example.com")),
581                linked_by_user_id: None,
582                link_reason: None,
583                verified_from_course_id: None,
584            },
585        )
586        .await
587        .unwrap();
588    }
589
590    async fn entered_state_long_ago(conn: &mut PgConnection, id: Uuid) {
591        let long_ago = Utc::now() - SUBMITTING_RECOVERY_GRACE - TimeDelta::minutes(1);
592        crate::credit_registrations::testing::set_state_entered_at_for_testing(conn, id, long_ago)
593            .await
594            .unwrap();
595        sqlx::query("UPDATE credit_registrations SET submitted_at = $2 WHERE id = $1")
596            .bind(id)
597            .bind(long_ago)
598            .execute(conn)
599            .await
600            .unwrap();
601    }
602
603    async fn first_failed_long_ago(conn: &mut PgConnection, id: Uuid) {
604        crate::credit_registrations::testing::set_first_failed_at_for_testing(
605            conn,
606            id,
607            Utc::now() - chrono::Duration::days(8),
608        )
609        .await
610        .unwrap();
611    }
612
613    async fn pause_module(conn: &mut PgConnection, course_module_id: Uuid, user_id: Uuid) {
614        crate::course_module_suotar_configurations::ensure_exists(conn, course_module_id)
615            .await
616            .unwrap();
617        crate::course_module_suotar_configurations::set_paused(
618            conn,
619            course_module_id,
620            Some(crate::course_module_suotar_configurations::SuotarPause {
621                paused_at: Utc::now(),
622                paused_by_user_id: user_id,
623                reason: None,
624            }),
625        )
626        .await
627        .unwrap();
628    }
629
630    fn rand_suffix() -> u32 {
631        use rand::RngExt;
632        rand::rng().random_range(1..99_999_999)
633    }
634
635    async fn recompute(conn: &mut PgConnection, fixture: &Fixture) -> i64 {
636        recompute_preconditions(
637            conn,
638            &RegistrationScope {
639                credit_registration_ids: vec![fixture.registration],
640                ..RegistrationScope::default()
641            },
642            PRECONDITIONS_LIMIT,
643        )
644        .await
645        .unwrap()
646    }
647
648    async fn state(conn: &mut PgConnection, fixture: &Fixture) -> CreditRegistrationState {
649        get_by_id(conn, fixture.registration).await.unwrap().state
650    }
651
652    #[tokio::test]
653    async fn an_eligible_completion_waits_for_a_linked_student_number() {
654        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
655        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
656
657        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
658        assert_eq!(
659            state(tx.as_mut(), &fixture).await,
660            CreditRegistrationState::Pending
661        );
662
663        link_student_number(tx.as_mut(), user).await;
664        assert_eq!(recompute(tx.as_mut(), &fixture).await, 1);
665        // Nobody has enrolled the moment they complete, so the first check waits for its rung.
666        let parked = get_by_id(tx.as_mut(), fixture.registration).await.unwrap();
667        assert_eq!(parked.state, CreditRegistrationState::NoUsableEnrolment);
668        assert_eq!(parked.enrolment_checked_at, None);
669        assert_eq!(parked.enrolment_check_step, Some(0));
670
671        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
672    }
673
674    #[tokio::test]
675    async fn a_row_left_submitting_by_a_dead_worker_becomes_uncertain() {
676        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
677        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
678        transition(
679            tx.as_mut(),
680            fixture.registration,
681            &Transition::planted(CreditRegistrationState::Submitting),
682        )
683        .await
684        .unwrap();
685
686        // A request that may still be in flight is left alone.
687        recompute(tx.as_mut(), &fixture).await;
688        assert_eq!(
689            state(tx.as_mut(), &fixture).await,
690            CreditRegistrationState::Submitting
691        );
692
693        entered_state_long_ago(tx.as_mut(), fixture.registration).await;
694        recompute(tx.as_mut(), &fixture).await;
695        assert_eq!(
696            state(tx.as_mut(), &fixture).await,
697            CreditRegistrationState::SubmissionUncertain
698        );
699    }
700
701    #[tokio::test]
702    async fn an_uncertain_row_is_never_moved_back_towards_import() {
703        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
704        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
705        link_student_number(tx.as_mut(), user).await;
706        transition(
707            tx.as_mut(),
708            fixture.registration,
709            &Transition::planted(CreditRegistrationState::SubmissionUncertain),
710        )
711        .await
712        .unwrap();
713
714        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
715        assert_eq!(
716            state(tx.as_mut(), &fixture).await,
717            CreditRegistrationState::SubmissionUncertain
718        );
719    }
720
721    #[tokio::test]
722    async fn losing_eligibility_blocks_a_row_and_regaining_it_unblocks_it() {
723        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
724        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
725        link_student_number(tx.as_mut(), user).await;
726        recompute(tx.as_mut(), &fixture).await;
727        assert_eq!(
728            state(tx.as_mut(), &fixture).await,
729            CreditRegistrationState::NoUsableEnrolment
730        );
731
732        crate::course_module_completions::update_needs_to_be_reviewed(
733            tx.as_mut(),
734            fixture.completion,
735            true,
736        )
737        .await
738        .unwrap();
739        recompute(tx.as_mut(), &fixture).await;
740        assert_eq!(
741            state(tx.as_mut(), &fixture).await,
742            CreditRegistrationState::Blocked
743        );
744
745        crate::course_module_completions::update_needs_to_be_reviewed(
746            tx.as_mut(),
747            fixture.completion,
748            false,
749        )
750        .await
751        .unwrap();
752        recompute(tx.as_mut(), &fixture).await;
753        assert_eq!(
754            state(tx.as_mut(), &fixture).await,
755            CreditRegistrationState::NoUsableEnrolment
756        );
757    }
758
759    #[tokio::test]
760    async fn a_deleted_completion_cancels_a_row_that_was_never_sent() {
761        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
762        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
763        crate::course_module_completions::delete(tx.as_mut(), fixture.completion)
764            .await
765            .unwrap();
766
767        recompute(tx.as_mut(), &fixture).await;
768        assert_eq!(
769            state(tx.as_mut(), &fixture).await,
770            CreditRegistrationState::Cancelled
771        );
772    }
773
774    #[tokio::test]
775    async fn unlinking_the_student_number_sends_a_queued_row_back_to_wait_for_one() {
776        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
777        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
778        link_student_number(tx.as_mut(), user).await;
779        recompute(tx.as_mut(), &fixture).await;
780        assert_eq!(
781            state(tx.as_mut(), &fixture).await,
782            CreditRegistrationState::NoUsableEnrolment
783        );
784
785        let linked = crate::verified_student_numbers::get_by_user_id(tx.as_mut(), user)
786            .await
787            .unwrap()
788            .expect("a linked number");
789        crate::verified_student_numbers::soft_delete(tx.as_mut(), linked.id)
790            .await
791            .unwrap();
792        recompute(tx.as_mut(), &fixture).await;
793        assert_eq!(
794            state(tx.as_mut(), &fixture).await,
795            CreditRegistrationState::Pending
796        );
797    }
798
799    #[tokio::test]
800    async fn a_retryable_row_resumes_where_it_had_got_to_once_its_backoff_elapses() {
801        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
802        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
803        link_student_number(tx.as_mut(), user).await;
804        transition(
805            tx.as_mut(),
806            fixture.registration,
807            &Transition::planted(CreditRegistrationState::FailedRetryable),
808        )
809        .await
810        .unwrap();
811        crate::credit_registrations::schedule_next_attempt(
812            tx.as_mut(),
813            fixture.registration,
814            Utc::now() + chrono::Duration::hours(1),
815        )
816        .await
817        .unwrap();
818
819        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
820
821        crate::credit_registrations::schedule_next_attempt(
822            tx.as_mut(),
823            fixture.registration,
824            Utc::now() - chrono::Duration::seconds(1),
825        )
826        .await
827        .unwrap();
828        recompute(tx.as_mut(), &fixture).await;
829        assert_eq!(
830            state(tx.as_mut(), &fixture).await,
831            CreditRegistrationState::ReadyToSubmit
832        );
833    }
834
835    #[tokio::test]
836    async fn a_row_that_kept_failing_for_a_week_becomes_a_support_case() {
837        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
838        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
839        link_student_number(tx.as_mut(), user).await;
840        transition(
841            tx.as_mut(),
842            fixture.registration,
843            &Transition::planted(CreditRegistrationState::FailedRetryable),
844        )
845        .await
846        .unwrap();
847        first_failed_long_ago(tx.as_mut(), fixture.registration).await;
848
849        recompute(tx.as_mut(), &fixture).await;
850        let row = get_by_id(tx.as_mut(), fixture.registration).await.unwrap();
851        assert_eq!(row.state, CreditRegistrationState::FailedPermanent);
852        assert_eq!(
853            row.error_code,
854            Some(CreditRegistrationErrorCode::RetryWindowExpired)
855        );
856        assert!(row.needs_admin_attention);
857    }
858
859    #[tokio::test]
860    async fn a_parked_row_is_claimed_for_its_check_where_it_stands() {
861        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
862        crate::course_modules::update(
863            tx.as_mut(),
864            course_module.id,
865            &crate::course_modules::NewCourseModule::new(
866                course_module.course_id,
867                course_module.name.clone(),
868                course_module.order_number,
869            )
870            .set_enable_credit_registration_via_suotar(true),
871        )
872        .await
873        .unwrap();
874        crate::course_modules::set_register_eligible_new_completions_via_suotar(
875            tx.as_mut(),
876            course_module.id,
877            true,
878        )
879        .await
880        .unwrap();
881        link_student_number(tx.as_mut(), user).await;
882        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
883        recompute(tx.as_mut(), &fixture).await;
884        let scope = RegistrationScope {
885            credit_registration_ids: vec![fixture.registration],
886            ..RegistrationScope::default()
887        };
888        let claim = async |conn: &mut PgConnection| {
889            crate::credit_registrations::claim_due_for_resolve(conn, &scope, 10)
890                .await
891                .unwrap()
892                .len()
893        };
894        assert_eq!(claim(tx.as_mut()).await, 0);
895
896        crate::credit_registrations::schedule_next_attempt(
897            tx.as_mut(),
898            fixture.registration,
899            Utc::now() - chrono::Duration::seconds(1),
900        )
901        .await
902        .unwrap();
903        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
904        assert_eq!(claim(tx.as_mut()).await, 1);
905        assert_eq!(
906            state(tx.as_mut(), &fixture).await,
907            CreditRegistrationState::NoUsableEnrolment
908        );
909
910        crate::credit_registrations::claim_enrolment_checks(tx.as_mut(), &[fixture.registration])
911            .await
912            .unwrap();
913        assert_eq!(claim(tx.as_mut()).await, 0);
914        let answered = transition(
915            tx.as_mut(),
916            fixture.registration,
917            &Transition {
918                next_attempt_at: Some(Utc::now() - chrono::Duration::seconds(1)),
919                ..Transition::to(CreditRegistrationState::NoUsableEnrolment)
920            },
921        )
922        .await
923        .unwrap();
924        assert!(answered.enrolment_checked_at.is_some());
925        assert_eq!(answered.enrolment_check_claimed_until, None);
926        assert_eq!(claim(tx.as_mut()).await, 1);
927    }
928
929    /// Its payload is already frozen, so resolving the enrolment again would be a loop.
930    #[tokio::test]
931    async fn a_row_queued_for_import_is_left_where_it_is() {
932        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
933        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
934        link_student_number(tx.as_mut(), user).await;
935        transition(
936            tx.as_mut(),
937            fixture.registration,
938            &Transition::planted(CreditRegistrationState::CheckingEnrolment),
939        )
940        .await
941        .unwrap();
942
943        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
944        assert_eq!(
945            state(tx.as_mut(), &fixture).await,
946            CreditRegistrationState::CheckingEnrolment
947        );
948    }
949
950    #[tokio::test]
951    async fn a_paused_module_stops_the_recompute_without_rewriting_anything() {
952        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
953        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
954        pause_module(tx.as_mut(), course_module.id, user).await;
955
956        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
957        assert_eq!(
958            state(tx.as_mut(), &fixture).await,
959            CreditRegistrationState::Pending
960        );
961    }
962
963    #[tokio::test]
964    async fn a_terminal_row_is_never_recomputed() {
965        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
966        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
967        transition(
968            tx.as_mut(),
969            fixture.registration,
970            &Transition::planted(CreditRegistrationState::Registered),
971        )
972        .await
973        .unwrap();
974
975        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
976        assert_eq!(
977            state(tx.as_mut(), &fixture).await,
978            CreditRegistrationState::Registered
979        );
980    }
981
982    #[tokio::test]
983    async fn a_reversed_registration_waits_for_a_human() {
984        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
985        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
986        transition(
987            tx.as_mut(),
988            fixture.registration,
989            &Transition::planted(CreditRegistrationState::Misregistered),
990        )
991        .await
992        .unwrap();
993
994        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
995        assert_eq!(
996            state(tx.as_mut(), &fixture).await,
997            CreditRegistrationState::Misregistered
998        );
999    }
1000
1001    #[test]
1002    fn a_retry_resumes_where_the_row_had_got_to() {
1003        assert_eq!(
1004            resume_state(false, false, false, false),
1005            CreditRegistrationState::ReadyToSubmit
1006        );
1007        assert_eq!(
1008            resume_state(false, true, false, false),
1009            CreditRegistrationState::CheckingEnrolment
1010        );
1011        assert_eq!(
1012            resume_state(true, true, false, false),
1013            CreditRegistrationState::AwaitingVerification
1014        );
1015    }
1016
1017    /// Resuming at `checking_enrolment` here would import the number the account no longer holds.
1018    #[test]
1019    fn a_retry_whose_frozen_identity_went_stale_resolves_the_enrolment_again() {
1020        assert_eq!(
1021            resume_state(false, true, true, false),
1022            CreditRegistrationState::ReadyToSubmit
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn a_scoped_recompute_leaves_another_students_row_alone() {
1028        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
1029        let mine = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
1030        link_student_number(tx.as_mut(), user).await;
1031        insert_data!(tx: tx; user: other_user);
1032        let theirs = fixture(
1033            tx.as_mut(),
1034            other_user,
1035            course,
1036            instance.id,
1037            course_module.id,
1038        )
1039        .await;
1040
1041        assert_eq!(
1042            recompute_preconditions(
1043                tx.as_mut(),
1044                &RegistrationScope {
1045                    user_id: Some(user),
1046                    ..RegistrationScope::default()
1047                },
1048                PRECONDITIONS_LIMIT
1049            )
1050            .await
1051            .unwrap(),
1052            1
1053        );
1054        assert_eq!(
1055            state(tx.as_mut(), &mine).await,
1056            CreditRegistrationState::NoUsableEnrolment
1057        );
1058        assert_eq!(
1059            state(tx.as_mut(), &theirs).await,
1060            CreditRegistrationState::Pending
1061        );
1062    }
1063}