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, or the start's own check ahead
171/// of it, unless that is due already.
172/// A row waiting there is left to resolve-enrolments, which checks it where it stands.
173pub async fn recompute_preconditions(
174    conn: &mut PgConnection,
175    scope: &RegistrationScope,
176    limit: i64,
177) -> ModelResult<i64> {
178    let now = Utc::now();
179    let mut tx = conn.begin().await?;
180    let mut starts = Vec::new();
181    let moves: Vec<BatchMove> = pending_moves(&mut tx, scope, limit)
182        .await?
183        .iter()
184        .filter_map(|pending| {
185            let mut target = match precondition_target(pending, now) {
186                Target::Stay => pending.state,
187                Target::Move(target) => target,
188                Target::Resume => resume_state(
189                    pending.has_submitted_attainment,
190                    pending.has_payload_snapshot,
191                    pending.frozen_identity_stale,
192                    pending.payload_unweighed_against_held_credit,
193                ),
194            };
195            let mut next_attempt_at = None;
196            if target == CreditRegistrationState::AwaitingVerification {
197                next_attempt_at = Some(backoff::next_attempt_at(
198                    now,
199                    verify_delay(pending.submitted_at, now),
200                ));
201            }
202            if target == CreditRegistrationState::ReadyToSubmit
203                && let Some(start) = &pending.check_start
204                && let Some(scheduled) = first_check(start.group, start.anchor_at)
205            {
206                // A rung already past when the schedule starts is due from now, or its lateness
207                // would count time before the row could be checked.
208                let scheduled = ScheduledEnrolmentCheck {
209                    due_at: scheduled.due_at.max(now),
210                    ..scheduled
211                };
212                starts.push(EnrolmentCheckStart {
213                    credit_registration_id: pending.id,
214                    group: start.group,
215                    anchor_at: start.anchor_at,
216                    scheduled,
217                    source: start.source,
218                });
219                let own_check_at = start.source.own_check_at(start.anchor_at);
220                if own_check_at.unwrap_or(scheduled.due_at) > now {
221                    target = CreditRegistrationState::NoUsableEnrolment;
222                    next_attempt_at = Some(own_check_at.unwrap_or_else(|| scheduled.release_at()));
223                }
224            }
225            (target != pending.state).then(|| BatchMove {
226                id: pending.id,
227                transition: Transition {
228                    next_attempt_at,
229                    ..precondition_move(pending, target).transition(
230                        // `pending_moves` reads without a row lock, so the guard refuses a stale
231                        // write instead of overwriting a row another phase already moved.
232                        Some(pending.state),
233                        now,
234                    )
235                },
236            })
237        })
238        .collect();
239    let moved = transition_batch(&mut tx, &moves).await?;
240    record_starts(&mut tx, &starts).await?;
241    tx.commit().await?;
242    Ok(moved)
243}
244
245/// The move each edge makes: kept out of the query so every edge's error code, admin flag and
246/// timeline line sit in one place.
247fn precondition_move(pending: &PendingMove, target: CreditRegistrationState) -> UnaskedMove {
248    use CreditRegistrationState as State;
249    let to = Outcome::to(target);
250    match target {
251        State::SubmissionUncertain => UnaskedMove::new(
252            to.with_code(CreditRegistrationErrorCode::SisuTimeout),
253            "Found still submitting after a restart, so the import may or may not have been \
254             processed. Only verification may touch it from here.",
255        ),
256        State::Cancelled => UnaskedMove::new(
257            to,
258            "The completion no longer exists and nothing had been submitted.",
259        ),
260        State::Blocked => {
261            UnaskedMove::new(to, "The completion is no longer eligible for registration.")
262        }
263        State::FailedPermanent => UnaskedMove::new(
264            to.with_code(CreditRegistrationErrorCode::RetryWindowExpired)
265                .needing_admin(),
266            "Retried for a week without success.",
267        ),
268        // The ledger does not record which precondition a `pending` row waits on, so the event is
269        // where the answer is kept for whoever reads the timeline later.
270        State::Pending => UnaskedMove {
271            outcome: to,
272            message: pending.preconditions.reason().map(|reason| {
273                match reason {
274                    CreditRegistrationPendingReason::Completion => {
275                        "The completion is not registrable yet."
276                    }
277                    CreditRegistrationPendingReason::StudentNumber => {
278                        NO_VERIFIED_STUDENT_NUMBER_MESSAGE
279                    }
280                    CreditRegistrationPendingReason::CourseCode => {
281                        "Suotar does not accept the module's course code, so nothing is sent \
282                         until it does."
283                    }
284                }
285                .to_string()
286            }),
287        },
288        State::NoUsableEnrolment => UnaskedMove::new(to, "Waiting for the first enrolment check."),
289        // Keys off `pending.state`, not just `target`: the message is about where the row came
290        // from, unlike every arm above.
291        State::ReadyToSubmit if pending.state == State::CheckingEnrolment => UnaskedMove::new(
292            to,
293            "The linked student number changed after this row's payload was frozen, so the \
294             enrolment is resolved again against the current one.",
295        ),
296        _ => UnaskedMove::silent(to),
297    }
298}
299
300/// The facts of the rows that may need a move, so `limit` cannot be spent on rows that need nothing.
301///
302/// The `WHERE` only narrows to the rows [`precondition_target`] could move, and must keep every one
303/// of them: the decision itself is that function's.
304async fn pending_moves(
305    conn: &mut PgConnection,
306    scope: &RegistrationScope,
307    limit: i64,
308) -> ModelResult<Vec<PendingMove>> {
309    let rows = sqlx::query!(
310        r#"
311WITH facts AS (
312  SELECT cr.id,
313    cr.state,
314    cr.next_attempt_at,
315    cr.state_entered_at,
316    cr.submitted_at,
317    cr.first_failed_at,
318    cr.submitted_attainment_id IS NOT NULL AS has_submitted_attainment,
319    (
320      cr.selected_enrolment_id IS NOT NULL
321      AND cr.grade_id IS NOT NULL
322    ) AS has_payload_snapshot,
323    p.completion_deleted,
324    p.completion_eligible AS eligible,
325    p.has_verified_student_number AS has_student_number,
326    p.course_code_allowed,
327    p.frozen_identity_stale,
328    EXISTS (
329      SELECT 1
330      FROM credit_registrations held
331      WHERE held.user_id = cr.user_id
332        AND held.course_module_id = cr.course_module_id
333        AND held.id <> cr.id
334        AND held.deleted_at IS NULL
335        AND held.superseded_by_id IS NULL
336        AND held.pending_superseded_by_id IS DISTINCT FROM cr.id
337        AND held.state = ANY($8::credit_registration_state [])
338    ) AS payload_unweighed_against_held_credit,
339    cr.state IN ('pending', 'blocked')
340    AND cr.enrolment_checked_at IS NULL AS starts_enrolment_checks,
341    GREATEST(
342      cr.enrolment_check_group,
343      CASE
344        WHEN sig.last_check_requested_at IS NOT NULL THEN 'check_requested'
345        WHEN sig.last_visited_at IS NOT NULL THEN 'visited'
346        ELSE 'completed'
347      END::enrolment_check_group,
348      (
349        SELECT MAX(earlier.enrolment_check_group)
350        FROM credit_registrations earlier
351        WHERE earlier.user_id = cr.user_id
352          AND earlier.course_module_id = cr.course_module_id
353          AND earlier.created_at < cr.created_at
354          AND earlier.deleted_at IS NULL
355      )
356    ) AS check_group,
357    GREATEST(
358      cmc.completion_date,
359      vsn.verified_at,
360      COALESCE(sig.last_check_requested_at, sig.last_visited_at)
361    ) AS check_anchor_at,
362    CASE
363      WHEN sig.last_check_requested_at IS NOT NULL THEN sig.check_request_source
364      ELSE 'schedule'
365    END::enrolment_check_source AS check_source
366  FROM credit_registrations cr
367    JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
368    JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
369    LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
370    AND vsn.deleted_at IS NULL
371    LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
372    AND sig.deleted_at IS NULL
373    LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
374    AND conf.deleted_at IS NULL
375  WHERE cr.deleted_at IS NULL
376    AND cr.superseded_by_id IS NULL
377    AND cr.terminal_at IS NULL
378    -- Only a human moves a row the study registry reversed.
379    AND cr.state <> 'misregistered'
380    AND conf.paused_at IS NULL
381    AND ($2::uuid IS NULL OR cr.course_id = $2)
382    AND ($3::uuid IS NULL OR cr.user_id = $3)
383    AND (
384      cardinality($4::uuid []) = 0
385      OR cr.id = ANY($4::uuid [])
386    )
387)
388SELECT id,
389  state AS "state: CreditRegistrationState",
390  next_attempt_at,
391  state_entered_at,
392  submitted_at,
393  first_failed_at,
394  completion_deleted AS "completion_deleted!",
395  eligible AS "eligible!",
396  has_student_number AS "has_student_number!",
397  course_code_allowed AS "course_code_allowed!",
398  has_submitted_attainment AS "has_submitted_attainment!",
399  has_payload_snapshot AS "has_payload_snapshot!",
400  frozen_identity_stale AS "frozen_identity_stale!",
401  payload_unweighed_against_held_credit AS "payload_unweighed_against_held_credit!",
402  starts_enrolment_checks AS "starts_enrolment_checks!",
403  check_group AS "check_group!",
404  check_anchor_at AS "check_anchor_at!",
405  check_source AS "check_source!"
406FROM facts
407WHERE (
408    state = 'submitting'
409    AND COALESCE(submitted_at, state_entered_at) < now() - $5::interval
410  )
411  OR (
412    state <> ALL($9::credit_registration_state [])
413    AND (
414      completion_deleted
415      OR (
416        state = 'failed_retryable'
417        AND (
418          NOT eligible
419          OR NOT has_student_number
420          OR (
421            NOT course_code_allowed
422            AND NOT has_submitted_attainment
423          )
424          OR next_attempt_at <= now()
425          OR first_failed_at < now() - $6::interval
426        )
427      )
428      OR (
429        state NOT IN ('failed_retryable', 'pending', 'blocked')
430        AND (
431          NOT eligible
432          OR NOT has_student_number
433          OR NOT course_code_allowed
434        )
435      )
436      OR (
437        state = 'blocked'
438        AND eligible
439      )
440      OR (
441        state = 'pending'
442        AND eligible
443        AND has_student_number
444        AND course_code_allowed
445      )
446      OR (
447        state = 'checking_enrolment'
448        AND frozen_identity_stale
449      )
450      OR (
451        state = 'resolving_enrolment'
452        AND state_entered_at < now() - $7::interval
453      )
454    )
455  )
456ORDER BY state_entered_at
457LIMIT $1
458        "#,
459        limit,
460        scope.course_id,
461        scope.user_id,
462        &scope.credit_registration_ids,
463        SUBMITTING_RECOVERY_GRACE as TimeDelta,
464        SUBMIT_MAX_RETRY_AGE as TimeDelta,
465        RESOLVING_RECOVERY_GRACE as TimeDelta,
466        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
467        &CreditRegistrationState::IN_FLIGHT_STATES as &[CreditRegistrationState],
468    )
469    .fetch_all(conn)
470    .await?;
471    Ok(rows
472        .into_iter()
473        .map(|row| PendingMove {
474            id: row.id,
475            state: row.state,
476            next_attempt_at: row.next_attempt_at,
477            state_entered_at: row.state_entered_at,
478            submitted_at: row.submitted_at,
479            first_failed_at: row.first_failed_at,
480            completion_deleted: row.completion_deleted,
481            preconditions: PendingPreconditions {
482                completion_eligible: row.eligible,
483                has_verified_student_number: row.has_student_number,
484                course_code_allowed: row.course_code_allowed,
485            },
486            has_submitted_attainment: row.has_submitted_attainment,
487            has_payload_snapshot: row.has_payload_snapshot,
488            frozen_identity_stale: row.frozen_identity_stale,
489            payload_unweighed_against_held_credit: row.payload_unweighed_against_held_credit,
490            check_start: row.starts_enrolment_checks.then_some(CheckStartFacts {
491                group: row.check_group,
492                anchor_at: row.check_anchor_at,
493                source: row.check_source,
494            }),
495        })
496        .collect())
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::course_module_completions::{
503        CourseModuleCompletionGranter, NewCourseModuleCompletion,
504    };
505    use crate::credit_registrations::{NewCreditRegistration, get_by_id, transition};
506    use crate::test_helper::*;
507    use crate::verified_student_numbers::{
508        NewVerifiedStudentNumber, StudentNumberVerificationMethod,
509    };
510
511    struct Fixture {
512        registration: Uuid,
513        completion: Uuid,
514    }
515
516    async fn fixture(
517        conn: &mut PgConnection,
518        user: Uuid,
519        course: Uuid,
520        instance: Uuid,
521        course_module: Uuid,
522    ) -> Fixture {
523        let completion = crate::course_module_completions::insert(
524            conn,
525            PKeyPolicy::Generate,
526            &NewCourseModuleCompletion {
527                course_id: course,
528                course_module_id: course_module,
529                user_id: user,
530                completion_date: Utc::now(),
531                completion_registration_attempt_date: None,
532                completion_language: "en".to_string(),
533                eligible_for_ects: true,
534                email: "student@example.com".to_string(),
535                grade: Some(4),
536                passed: true,
537            },
538            CourseModuleCompletionGranter::Automatic,
539        )
540        .await
541        .unwrap();
542        // Defaults to false, which the recompute reads as an unmet prerequisite.
543        crate::course_module_completions::update_prerequisite_modules_completed(
544            conn,
545            completion.id,
546            true,
547        )
548        .await
549        .unwrap();
550        let registration = crate::credit_registrations::insert(
551            conn,
552            PKeyPolicy::Generate,
553            &NewCreditRegistration {
554                course_module_completion_id: completion.id,
555                user_id: user,
556                course_id: course,
557                course_module_id: course_module,
558                course_instance_id: instance,
559                attempt_number: 1,
560            },
561            None,
562        )
563        .await
564        .unwrap();
565        Fixture {
566            registration,
567            completion: completion.id,
568        }
569    }
570
571    async fn link_student_number(conn: &mut PgConnection, user: Uuid) {
572        crate::verified_student_numbers::insert(
573            conn,
574            PKeyPolicy::Generate,
575            &NewVerifiedStudentNumber {
576                user_id: user,
577                student_number: DbSecret::new(format!("9{:08}", rand_suffix())),
578                sisu_person_id: DbSecret::new(format!("hy-hlo-{}", rand_suffix())),
579                first_names: None,
580                last_name: None,
581                verified_via: StudentNumberVerificationMethod::EmailedLink,
582                verified_via_email: Some(DbSecret::new("student@helsinki.example.com")),
583                linked_by_user_id: None,
584                link_reason: None,
585                verified_from_course_id: None,
586            },
587        )
588        .await
589        .unwrap();
590    }
591
592    async fn entered_state_long_ago(conn: &mut PgConnection, id: Uuid) {
593        let long_ago = Utc::now() - SUBMITTING_RECOVERY_GRACE - TimeDelta::minutes(1);
594        crate::credit_registrations::testing::set_state_entered_at_for_testing(conn, id, long_ago)
595            .await
596            .unwrap();
597        crate::credit_registrations::set_submitted_at(conn, id, long_ago)
598            .await
599            .unwrap();
600    }
601
602    async fn first_failed_long_ago(conn: &mut PgConnection, id: Uuid) {
603        crate::credit_registrations::testing::set_first_failed_at_for_testing(
604            conn,
605            id,
606            Utc::now() - chrono::Duration::days(8),
607        )
608        .await
609        .unwrap();
610    }
611
612    async fn pause_module(conn: &mut PgConnection, course_module_id: Uuid, user_id: Uuid) {
613        crate::course_module_suotar_configurations::ensure_exists(conn, course_module_id)
614            .await
615            .unwrap();
616        crate::course_module_suotar_configurations::set_paused(
617            conn,
618            course_module_id,
619            Some(crate::course_module_suotar_configurations::SuotarPause {
620                paused_at: Utc::now(),
621                paused_by_user_id: user_id,
622                reason: None,
623            }),
624        )
625        .await
626        .unwrap();
627    }
628
629    fn rand_suffix() -> u32 {
630        use rand::RngExt;
631        rand::rng().random_range(1..99_999_999)
632    }
633
634    async fn recompute(conn: &mut PgConnection, fixture: &Fixture) -> i64 {
635        recompute_preconditions(
636            conn,
637            &RegistrationScope {
638                credit_registration_ids: vec![fixture.registration],
639                ..RegistrationScope::default()
640            },
641            PRECONDITIONS_LIMIT,
642        )
643        .await
644        .unwrap()
645    }
646
647    async fn state(conn: &mut PgConnection, fixture: &Fixture) -> CreditRegistrationState {
648        get_by_id(conn, fixture.registration).await.unwrap().state
649    }
650
651    #[tokio::test]
652    async fn an_eligible_completion_waits_for_a_linked_student_number() {
653        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
654        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
655
656        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
657        assert_eq!(
658            state(tx.as_mut(), &fixture).await,
659            CreditRegistrationState::Pending
660        );
661
662        link_student_number(tx.as_mut(), user).await;
663        assert_eq!(recompute(tx.as_mut(), &fixture).await, 1);
664        // Nobody has enrolled the moment they complete, so the first check waits for its rung.
665        let parked = get_by_id(tx.as_mut(), fixture.registration).await.unwrap();
666        assert_eq!(parked.state, CreditRegistrationState::NoUsableEnrolment);
667        assert_eq!(parked.enrolment_checked_at, None);
668        assert_eq!(parked.enrolment_check_step, Some(0));
669
670        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
671    }
672
673    #[tokio::test]
674    async fn a_row_left_submitting_by_a_dead_worker_becomes_uncertain() {
675        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
676        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
677        transition(
678            tx.as_mut(),
679            fixture.registration,
680            &Transition::planted(CreditRegistrationState::Submitting),
681        )
682        .await
683        .unwrap();
684
685        // A request that may still be in flight is left alone.
686        recompute(tx.as_mut(), &fixture).await;
687        assert_eq!(
688            state(tx.as_mut(), &fixture).await,
689            CreditRegistrationState::Submitting
690        );
691
692        entered_state_long_ago(tx.as_mut(), fixture.registration).await;
693        recompute(tx.as_mut(), &fixture).await;
694        assert_eq!(
695            state(tx.as_mut(), &fixture).await,
696            CreditRegistrationState::SubmissionUncertain
697        );
698    }
699
700    #[tokio::test]
701    async fn an_uncertain_row_is_never_moved_back_towards_import() {
702        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
703        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
704        link_student_number(tx.as_mut(), user).await;
705        transition(
706            tx.as_mut(),
707            fixture.registration,
708            &Transition::planted(CreditRegistrationState::SubmissionUncertain),
709        )
710        .await
711        .unwrap();
712
713        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
714        assert_eq!(
715            state(tx.as_mut(), &fixture).await,
716            CreditRegistrationState::SubmissionUncertain
717        );
718    }
719
720    #[tokio::test]
721    async fn losing_eligibility_blocks_a_row_and_regaining_it_unblocks_it() {
722        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
723        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
724        link_student_number(tx.as_mut(), user).await;
725        recompute(tx.as_mut(), &fixture).await;
726        assert_eq!(
727            state(tx.as_mut(), &fixture).await,
728            CreditRegistrationState::NoUsableEnrolment
729        );
730
731        crate::course_module_completions::update_needs_to_be_reviewed(
732            tx.as_mut(),
733            fixture.completion,
734            true,
735        )
736        .await
737        .unwrap();
738        recompute(tx.as_mut(), &fixture).await;
739        assert_eq!(
740            state(tx.as_mut(), &fixture).await,
741            CreditRegistrationState::Blocked
742        );
743
744        crate::course_module_completions::update_needs_to_be_reviewed(
745            tx.as_mut(),
746            fixture.completion,
747            false,
748        )
749        .await
750        .unwrap();
751        recompute(tx.as_mut(), &fixture).await;
752        assert_eq!(
753            state(tx.as_mut(), &fixture).await,
754            CreditRegistrationState::NoUsableEnrolment
755        );
756    }
757
758    #[tokio::test]
759    async fn a_deleted_completion_cancels_a_row_that_was_never_sent() {
760        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
761        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
762        crate::course_module_completions::delete(tx.as_mut(), fixture.completion)
763            .await
764            .unwrap();
765
766        recompute(tx.as_mut(), &fixture).await;
767        assert_eq!(
768            state(tx.as_mut(), &fixture).await,
769            CreditRegistrationState::Cancelled
770        );
771    }
772
773    #[tokio::test]
774    async fn unlinking_the_student_number_sends_a_queued_row_back_to_wait_for_one() {
775        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
776        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
777        link_student_number(tx.as_mut(), user).await;
778        recompute(tx.as_mut(), &fixture).await;
779        assert_eq!(
780            state(tx.as_mut(), &fixture).await,
781            CreditRegistrationState::NoUsableEnrolment
782        );
783
784        let linked = crate::verified_student_numbers::get_by_user_id(tx.as_mut(), user)
785            .await
786            .unwrap()
787            .expect("a linked number");
788        crate::verified_student_numbers::soft_delete(tx.as_mut(), linked.id)
789            .await
790            .unwrap();
791        recompute(tx.as_mut(), &fixture).await;
792        assert_eq!(
793            state(tx.as_mut(), &fixture).await,
794            CreditRegistrationState::Pending
795        );
796    }
797
798    #[tokio::test]
799    async fn a_retryable_row_resumes_where_it_had_got_to_once_its_backoff_elapses() {
800        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
801        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
802        link_student_number(tx.as_mut(), user).await;
803        transition(
804            tx.as_mut(),
805            fixture.registration,
806            &Transition::planted(CreditRegistrationState::FailedRetryable),
807        )
808        .await
809        .unwrap();
810        crate::credit_registrations::schedule_next_attempt(
811            tx.as_mut(),
812            fixture.registration,
813            Utc::now() + chrono::Duration::hours(1),
814        )
815        .await
816        .unwrap();
817
818        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
819
820        crate::credit_registrations::schedule_next_attempt(
821            tx.as_mut(),
822            fixture.registration,
823            Utc::now() - chrono::Duration::seconds(1),
824        )
825        .await
826        .unwrap();
827        recompute(tx.as_mut(), &fixture).await;
828        assert_eq!(
829            state(tx.as_mut(), &fixture).await,
830            CreditRegistrationState::ReadyToSubmit
831        );
832    }
833
834    #[tokio::test]
835    async fn a_row_that_kept_failing_for_a_week_becomes_a_support_case() {
836        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
837        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
838        link_student_number(tx.as_mut(), user).await;
839        transition(
840            tx.as_mut(),
841            fixture.registration,
842            &Transition::planted(CreditRegistrationState::FailedRetryable),
843        )
844        .await
845        .unwrap();
846        first_failed_long_ago(tx.as_mut(), fixture.registration).await;
847
848        recompute(tx.as_mut(), &fixture).await;
849        let row = get_by_id(tx.as_mut(), fixture.registration).await.unwrap();
850        assert_eq!(row.state, CreditRegistrationState::FailedPermanent);
851        assert_eq!(
852            row.error_code,
853            Some(CreditRegistrationErrorCode::RetryWindowExpired)
854        );
855        assert!(row.needs_admin_attention);
856    }
857
858    #[tokio::test]
859    async fn a_parked_row_is_claimed_for_its_check_where_it_stands() {
860        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
861        crate::course_modules::update_uh_course_code(
862            tx.as_mut(),
863            course_module.id,
864            Some("TKT10001".to_string()),
865        )
866        .await
867        .unwrap();
868        link_student_number(tx.as_mut(), user).await;
869        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
870        recompute(tx.as_mut(), &fixture).await;
871        let scope = RegistrationScope {
872            credit_registration_ids: vec![fixture.registration],
873            ..RegistrationScope::default()
874        };
875        let claim = async |conn: &mut PgConnection| {
876            crate::credit_registrations::claim_due_for_resolve(conn, &scope, 10)
877                .await
878                .unwrap()
879                .len()
880        };
881        assert_eq!(claim(tx.as_mut()).await, 0);
882
883        crate::credit_registrations::schedule_next_attempt(
884            tx.as_mut(),
885            fixture.registration,
886            Utc::now() - chrono::Duration::seconds(1),
887        )
888        .await
889        .unwrap();
890        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
891        assert_eq!(claim(tx.as_mut()).await, 1);
892        assert_eq!(
893            state(tx.as_mut(), &fixture).await,
894            CreditRegistrationState::NoUsableEnrolment
895        );
896
897        crate::credit_registrations::claim_enrolment_checks(tx.as_mut(), &[fixture.registration])
898            .await
899            .unwrap();
900        assert_eq!(claim(tx.as_mut()).await, 0);
901        let answered = transition(
902            tx.as_mut(),
903            fixture.registration,
904            &Transition {
905                next_attempt_at: Some(Utc::now() - chrono::Duration::seconds(1)),
906                ..Transition::to(CreditRegistrationState::NoUsableEnrolment)
907            },
908        )
909        .await
910        .unwrap();
911        assert!(answered.enrolment_checked_at.is_some());
912        assert_eq!(answered.enrolment_check_claimed_until, None);
913        assert_eq!(claim(tx.as_mut()).await, 1);
914    }
915
916    /// Its payload is already frozen, so resolving the enrolment again would be a loop.
917    #[tokio::test]
918    async fn a_row_queued_for_import_is_left_where_it_is() {
919        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
920        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
921        link_student_number(tx.as_mut(), user).await;
922        transition(
923            tx.as_mut(),
924            fixture.registration,
925            &Transition::planted(CreditRegistrationState::CheckingEnrolment),
926        )
927        .await
928        .unwrap();
929
930        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
931        assert_eq!(
932            state(tx.as_mut(), &fixture).await,
933            CreditRegistrationState::CheckingEnrolment
934        );
935    }
936
937    #[tokio::test]
938    async fn a_paused_module_stops_the_recompute_without_rewriting_anything() {
939        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
940        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
941        pause_module(tx.as_mut(), course_module.id, user).await;
942
943        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
944        assert_eq!(
945            state(tx.as_mut(), &fixture).await,
946            CreditRegistrationState::Pending
947        );
948    }
949
950    #[tokio::test]
951    async fn a_terminal_row_is_never_recomputed() {
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        transition(
955            tx.as_mut(),
956            fixture.registration,
957            &Transition::planted(CreditRegistrationState::Registered),
958        )
959        .await
960        .unwrap();
961
962        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
963        assert_eq!(
964            state(tx.as_mut(), &fixture).await,
965            CreditRegistrationState::Registered
966        );
967    }
968
969    #[tokio::test]
970    async fn a_reversed_registration_waits_for_a_human() {
971        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
972        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
973        transition(
974            tx.as_mut(),
975            fixture.registration,
976            &Transition::planted(CreditRegistrationState::Misregistered),
977        )
978        .await
979        .unwrap();
980
981        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
982        assert_eq!(
983            state(tx.as_mut(), &fixture).await,
984            CreditRegistrationState::Misregistered
985        );
986    }
987
988    #[test]
989    fn a_retry_resumes_where_the_row_had_got_to() {
990        assert_eq!(
991            resume_state(false, false, false, false),
992            CreditRegistrationState::ReadyToSubmit
993        );
994        assert_eq!(
995            resume_state(false, true, false, false),
996            CreditRegistrationState::CheckingEnrolment
997        );
998        assert_eq!(
999            resume_state(true, true, false, false),
1000            CreditRegistrationState::AwaitingVerification
1001        );
1002    }
1003
1004    /// Resuming at `checking_enrolment` here would import the number the account no longer holds.
1005    #[test]
1006    fn a_retry_whose_frozen_identity_went_stale_resolves_the_enrolment_again() {
1007        assert_eq!(
1008            resume_state(false, true, true, false),
1009            CreditRegistrationState::ReadyToSubmit
1010        );
1011    }
1012
1013    #[tokio::test]
1014    async fn a_scoped_recompute_leaves_another_students_row_alone() {
1015        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
1016        let mine = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
1017        link_student_number(tx.as_mut(), user).await;
1018        insert_data!(tx: tx; user: other_user);
1019        let theirs = fixture(
1020            tx.as_mut(),
1021            other_user,
1022            course,
1023            instance.id,
1024            course_module.id,
1025        )
1026        .await;
1027
1028        assert_eq!(
1029            recompute_preconditions(
1030                tx.as_mut(),
1031                &RegistrationScope {
1032                    user_id: Some(user),
1033                    ..RegistrationScope::default()
1034                },
1035                PRECONDITIONS_LIMIT
1036            )
1037            .await
1038            .unwrap(),
1039            1
1040        );
1041        assert_eq!(
1042            state(tx.as_mut(), &mine).await,
1043            CreditRegistrationState::NoUsableEnrolment
1044        );
1045        assert_eq!(
1046            state(tx.as_mut(), &theirs).await,
1047            CreditRegistrationState::Pending
1048        );
1049    }
1050}