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::*;
9
10use super::backoff::{SUBMIT_MAX_RETRY_AGE_SECS, SUBMITTING_RECOVERY_GRACE_SECS};
11use super::pending_reason::{CreditRegistrationPendingReason, PendingPreconditions};
12
13/// How many rows one iteration may move.
14pub const PRECONDITIONS_LIMIT: i64 = 500;
15
16#[derive(Debug, Clone, PartialEq)]
17struct PendingMove {
18    id: Uuid,
19    state: CreditRegistrationState,
20    /// `None` for a row whose backoff has elapsed; where it resumes is decided by [`resume_state`].
21    target: Option<CreditRegistrationState>,
22    /// Names the blocker in the audit event when the target is `pending`.
23    preconditions: PendingPreconditions,
24    has_submitted_attainment: bool,
25    has_payload_snapshot: bool,
26    frozen_identity_stale: bool,
27}
28
29/// Where a `failed_retryable` row goes when its backoff elapses, derived from how far it had got.
30/// Never `submitting`: only the import phase writes that, in the transaction before it sends.
31///
32/// `frozen_identity_stale` demotes a frozen payload to no payload at all. Nothing ever clears
33/// `selected_enrolment_id`/`grade_id`, so a row sent back to re-resolve after a relink still looks
34/// frozen; without this it would resume at `checking_enrolment` and import the previous number.
35fn resume_state(
36    has_submitted_attainment_id: bool,
37    has_payload_snapshot: bool,
38    frozen_identity_stale: bool,
39) -> CreditRegistrationState {
40    if has_submitted_attainment_id {
41        CreditRegistrationState::AwaitingVerification
42    } else if has_payload_snapshot && !frozen_identity_stale {
43        CreditRegistrationState::CheckingEnrolment
44    } else {
45        CreditRegistrationState::ReadyToSubmit
46    }
47}
48
49/// Applies at most `limit` moves to the scoped rows and returns how many moved.
50///
51/// A row a worker claimed between the snapshot and the write is left where it is: its state is that
52/// phase's to own now, and the next iteration decides again from whatever it committed. Writing
53/// anyway could put an in-flight import back into a state a second import claims.
54pub async fn recompute_preconditions(
55    conn: &mut PgConnection,
56    scope: &RegistrationScope,
57    limit: i64,
58) -> ModelResult<i64> {
59    let moves: Vec<BatchMove> = pending_moves(conn, scope, limit)
60        .await?
61        .iter()
62        .filter_map(|pending| {
63            let target = pending.target.unwrap_or_else(|| {
64                resume_state(
65                    pending.has_submitted_attainment,
66                    pending.has_payload_snapshot,
67                    pending.frozen_identity_stale,
68                )
69            });
70            (target != pending.state).then(|| BatchMove {
71                id: pending.id,
72                transition: transition_for(pending, target),
73            })
74        })
75        .collect();
76    transition_batch(conn, &moves).await
77}
78
79/// The transition each edge writes: kept out of the query so every edge's error code, admin flag
80/// and audit message sit in one place.
81fn transition_for(pending: &PendingMove, target: CreditRegistrationState) -> Transition {
82    use CreditRegistrationState as State;
83    let base = Transition {
84        // `pending_moves` reads without a row lock, so a phase can claim and move the row in the
85        // gap before this write. Guarding on the state we decided from turns that into a refusal
86        // instead of overwriting, say, a `submitting` row whose request is already out.
87        expected_from_state: Some(pending.state),
88        ..Transition::to(target)
89    };
90    match target {
91        State::SubmissionUncertain => Transition {
92            error_code: Some(CreditRegistrationErrorCode::SisuTimeout),
93            event_message: Some(
94                "Found still submitting after a restart, so the import may or may not have been \
95                 processed. Only verification may touch it from here."
96                    .to_string(),
97            ),
98            ..base
99        },
100        State::Cancelled => Transition {
101            event_message: Some(
102                "The completion no longer exists and nothing had been submitted.".to_string(),
103            ),
104            ..base
105        },
106        State::Blocked => Transition {
107            event_message: Some(
108                "The completion is no longer eligible for registration.".to_string(),
109            ),
110            ..base
111        },
112        State::FailedPermanent => Transition {
113            error_code: Some(CreditRegistrationErrorCode::RetryWindowExpired),
114            needs_admin_attention: Some(true),
115            event_message: Some("Retried for a week without success.".to_string()),
116            ..base
117        },
118        // The ledger does not record which precondition a `pending` row waits on, so the event is
119        // where the answer is kept for whoever reads the timeline later.
120        State::Pending => Transition {
121            event_message: pending.preconditions.reason().map(|reason| {
122                match reason {
123                    CreditRegistrationPendingReason::Completion => {
124                        "The completion is not registrable yet."
125                    }
126                    CreditRegistrationPendingReason::StudentNumber => {
127                        "No verified student number is linked to the account."
128                    }
129                }
130                .to_string()
131            }),
132            ..base
133        },
134        // Keys off `pending.state`, not just `target`: the message is about where the row came
135        // from, unlike every arm above.
136        State::ReadyToSubmit if pending.state == State::CheckingEnrolment => Transition {
137            event_message: Some(
138                "The linked student number changed after this row's payload was frozen, so the \
139                 enrolment is resolved again against the current one."
140                    .to_string(),
141            ),
142            ..base
143        },
144        _ => base,
145    }
146}
147
148/// Only the rows whose facts disagree with the state they are in, so `limit` cannot be spent on
149/// rows that need nothing.
150async fn pending_moves(
151    conn: &mut PgConnection,
152    scope: &RegistrationScope,
153    limit: i64,
154) -> ModelResult<Vec<PendingMove>> {
155    let rows = sqlx::query!(
156        r#"
157WITH facts AS (
158  SELECT cr.id,
159    cr.state,
160    cr.next_attempt_at,
161    cr.state_entered_at,
162    cr.first_failed_at,
163    cr.submitted_attainment_id IS NOT NULL AS has_submitted_attainment,
164    (
165      cr.selected_enrolment_id IS NOT NULL
166      AND cr.grade_id IS NOT NULL
167    ) AS has_payload_snapshot,
168    p.completion_deleted,
169    p.completion_eligible AS eligible,
170    p.has_verified_student_number AS has_student_number,
171    p.frozen_identity_stale
172  FROM credit_registrations cr
173    JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
174    LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
175    AND conf.deleted_at IS NULL
176  WHERE cr.deleted_at IS NULL
177    AND cr.superseded_by_id IS NULL
178    AND cr.terminal_at IS NULL
179    -- Only a human moves a row the study registry reversed.
180    AND cr.state <> 'misregistered'
181    AND conf.paused_at IS NULL
182    AND ($2::uuid IS NULL OR cr.course_id = $2)
183    AND ($3::uuid IS NULL OR cr.user_id = $3)
184    AND (
185      cardinality($4::uuid []) = 0
186      OR cr.id = ANY($4::uuid [])
187    )
188),
189targets AS (
190  SELECT facts.*,
191    CASE
192      -- A worker committed `submitting` and never came back with an answer. There is no way to
193      -- know whether the request landed, so the row is never imported again.
194      WHEN facts.state = 'submitting'
195      AND facts.state_entered_at < now() - ($5::bigint * INTERVAL '1 second') THEN 'submission_uncertain'
196      WHEN facts.state IN (
197        'submitting',
198        'submission_uncertain',
199        'awaiting_verification'
200      ) THEN facts.state
201      WHEN facts.completion_deleted THEN 'cancelled'
202      WHEN facts.state = 'failed_retryable'
203      AND NOT facts.eligible THEN 'blocked'
204      WHEN facts.state = 'failed_retryable'
205      AND facts.first_failed_at < now() - ($6::bigint * INTERVAL '1 second') THEN 'failed_permanent'
206      -- Before the resume arm below, or a retry would carry on past a precondition the student has
207      -- since removed and import would send the frozen student_number under a link they gave up.
208      WHEN facts.state = 'failed_retryable'
209      AND NOT facts.has_student_number THEN 'pending'
210      -- Resumed at whichever state matches how far it had got; decided outside this query.
211      WHEN facts.state = 'failed_retryable'
212      AND facts.next_attempt_at <= now() THEN NULL
213      WHEN facts.state = 'failed_retryable' THEN facts.state
214      -- Eligibility lost after the row had already moved on is what `blocked` is for; a row still
215      -- waiting is simply where it belongs, and the reason it reports changes to say so.
216      WHEN NOT facts.eligible
217      AND facts.state <> 'pending' THEN 'blocked'
218      WHEN NOT facts.eligible
219      OR NOT facts.has_student_number THEN 'pending'
220      -- The periodic look for an enrolment that may have appeared since.
221      WHEN facts.state = 'no_usable_enrolment'
222      AND facts.next_attempt_at > now() THEN facts.state
223      -- A relink after the payload was frozen must not let the row import against the account's
224      -- previous number: send it back to resolve a fresh payload against the current one.
225      WHEN facts.state = 'checking_enrolment'
226      AND facts.frozen_identity_stale THEN 'ready_to_submit'
227      -- Already queued for import with its payload frozen; sending it back would resolve again
228      -- forever.
229      WHEN facts.state = 'checking_enrolment' THEN facts.state
230      -- A resolve-enrolments call for this row is in flight; only that phase's own commit may
231      -- move it, or import could claim it before the enrolment is actually resolved.
232      WHEN facts.state = 'resolving_enrolment' THEN facts.state
233      ELSE 'ready_to_submit'
234    END::credit_registration_state AS target
235  FROM facts
236)
237SELECT id,
238  state AS "state: CreditRegistrationState",
239  target AS "target?: CreditRegistrationState",
240  eligible AS "eligible!",
241  has_student_number AS "has_student_number!",
242  has_submitted_attainment AS "has_submitted_attainment!",
243  has_payload_snapshot AS "has_payload_snapshot!",
244  frozen_identity_stale AS "frozen_identity_stale!"
245FROM targets
246WHERE target IS NULL
247  OR target <> state
248ORDER BY state_entered_at
249LIMIT $1
250        "#,
251        limit,
252        scope.course_id,
253        scope.user_id,
254        &scope.credit_registration_ids,
255        SUBMITTING_RECOVERY_GRACE_SECS,
256        SUBMIT_MAX_RETRY_AGE_SECS,
257    )
258    .fetch_all(conn)
259    .await?;
260    Ok(rows
261        .into_iter()
262        .map(|row| PendingMove {
263            id: row.id,
264            state: row.state,
265            target: row.target,
266            preconditions: PendingPreconditions {
267                completion_eligible: row.eligible,
268                has_verified_student_number: row.has_student_number,
269            },
270            has_submitted_attainment: row.has_submitted_attainment,
271            has_payload_snapshot: row.has_payload_snapshot,
272            frozen_identity_stale: row.frozen_identity_stale,
273        })
274        .collect())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::course_module_completions::{
281        CourseModuleCompletionGranter, NewCourseModuleCompletion,
282    };
283    use crate::credit_registrations::{NewCreditRegistration, get_by_id, transition};
284    use crate::test_helper::*;
285    use crate::verified_student_numbers::{
286        NewVerifiedStudentNumber, StudentNumberVerificationMethod,
287    };
288
289    struct Fixture {
290        registration: Uuid,
291        completion: Uuid,
292    }
293
294    async fn fixture(
295        conn: &mut PgConnection,
296        user: Uuid,
297        course: Uuid,
298        instance: Uuid,
299        course_module: Uuid,
300    ) -> Fixture {
301        let completion = crate::course_module_completions::insert(
302            conn,
303            PKeyPolicy::Generate,
304            &NewCourseModuleCompletion {
305                course_id: course,
306                course_module_id: course_module,
307                user_id: user,
308                completion_date: Utc::now(),
309                completion_registration_attempt_date: None,
310                completion_language: "en".to_string(),
311                eligible_for_ects: true,
312                email: "student@example.com".to_string(),
313                grade: Some(4),
314                passed: true,
315            },
316            CourseModuleCompletionGranter::Automatic,
317        )
318        .await
319        .unwrap();
320        // Defaults to false, which the recompute reads as an unmet prerequisite.
321        crate::course_module_completions::update_prerequisite_modules_completed(
322            conn,
323            completion.id,
324            true,
325        )
326        .await
327        .unwrap();
328        let registration = crate::credit_registrations::insert(
329            conn,
330            PKeyPolicy::Generate,
331            &NewCreditRegistration {
332                course_module_completion_id: completion.id,
333                user_id: user,
334                course_id: course,
335                course_module_id: course_module,
336                course_instance_id: instance,
337                attempt_number: 1,
338            },
339            None,
340        )
341        .await
342        .unwrap();
343        Fixture {
344            registration,
345            completion: completion.id,
346        }
347    }
348
349    async fn link_student_number(conn: &mut PgConnection, user: Uuid) {
350        crate::verified_student_numbers::insert(
351            conn,
352            PKeyPolicy::Generate,
353            &NewVerifiedStudentNumber {
354                user_id: user,
355                student_number: format!("9{:08}", rand_suffix()),
356                sisu_person_id: format!("hy-hlo-{}", rand_suffix()),
357                first_names: None,
358                last_name: None,
359                verified_via: StudentNumberVerificationMethod::EmailedLink,
360                verified_via_email: Some("student@helsinki.example".to_string()),
361                verified_via_email_match_field: None,
362                account_email_verified_at: None,
363                linked_by_user_id: None,
364                link_reason: None,
365                verified_from_course_id: None,
366            },
367        )
368        .await
369        .unwrap();
370    }
371
372    async fn entered_state_long_ago(conn: &mut PgConnection, id: Uuid) {
373        crate::credit_registrations::set_state_entered_at_for_testing(
374            conn,
375            id,
376            Utc::now() - chrono::Duration::hours(1),
377        )
378        .await
379        .unwrap();
380    }
381
382    async fn first_failed_long_ago(conn: &mut PgConnection, id: Uuid) {
383        crate::credit_registrations::set_first_failed_at_for_testing(
384            conn,
385            id,
386            Utc::now() - chrono::Duration::days(8),
387        )
388        .await
389        .unwrap();
390    }
391
392    async fn pause_module(conn: &mut PgConnection, course_module_id: Uuid, user_id: Uuid) {
393        crate::course_module_suotar_configurations::upsert(conn, course_module_id, None, None)
394            .await
395            .unwrap();
396        crate::course_module_suotar_configurations::set_paused(
397            conn,
398            course_module_id,
399            Some(crate::course_module_suotar_configurations::SuotarPause {
400                paused_at: Utc::now(),
401                paused_by_user_id: user_id,
402                reason: None,
403            }),
404        )
405        .await
406        .unwrap();
407    }
408
409    fn rand_suffix() -> u32 {
410        use rand::RngExt;
411        rand::rng().random_range(1..99_999_999)
412    }
413
414    async fn recompute(conn: &mut PgConnection, fixture: &Fixture) -> i64 {
415        recompute_preconditions(
416            conn,
417            &RegistrationScope {
418                credit_registration_ids: vec![fixture.registration],
419                ..RegistrationScope::default()
420            },
421            PRECONDITIONS_LIMIT,
422        )
423        .await
424        .unwrap()
425    }
426
427    async fn state(conn: &mut PgConnection, fixture: &Fixture) -> CreditRegistrationState {
428        get_by_id(conn, fixture.registration).await.unwrap().state
429    }
430
431    #[tokio::test]
432    async fn an_eligible_completion_waits_for_a_linked_student_number() {
433        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
434        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
435
436        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
437        assert_eq!(
438            state(tx.as_mut(), &fixture).await,
439            CreditRegistrationState::Pending
440        );
441
442        link_student_number(tx.as_mut(), user).await;
443        assert_eq!(recompute(tx.as_mut(), &fixture).await, 1);
444        assert_eq!(
445            state(tx.as_mut(), &fixture).await,
446            CreditRegistrationState::ReadyToSubmit
447        );
448
449        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
450    }
451
452    #[tokio::test]
453    async fn a_row_left_submitting_by_a_dead_worker_becomes_uncertain() {
454        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
455        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
456        transition(
457            tx.as_mut(),
458            fixture.registration,
459            &Transition::planted(CreditRegistrationState::Submitting),
460        )
461        .await
462        .unwrap();
463
464        // A request that may still be in flight is left alone.
465        recompute(tx.as_mut(), &fixture).await;
466        assert_eq!(
467            state(tx.as_mut(), &fixture).await,
468            CreditRegistrationState::Submitting
469        );
470
471        entered_state_long_ago(tx.as_mut(), fixture.registration).await;
472        recompute(tx.as_mut(), &fixture).await;
473        assert_eq!(
474            state(tx.as_mut(), &fixture).await,
475            CreditRegistrationState::SubmissionUncertain
476        );
477    }
478
479    #[tokio::test]
480    async fn an_uncertain_row_is_never_moved_back_towards_import() {
481        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
482        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
483        link_student_number(tx.as_mut(), user).await;
484        transition(
485            tx.as_mut(),
486            fixture.registration,
487            &Transition::planted(CreditRegistrationState::SubmissionUncertain),
488        )
489        .await
490        .unwrap();
491
492        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
493        assert_eq!(
494            state(tx.as_mut(), &fixture).await,
495            CreditRegistrationState::SubmissionUncertain
496        );
497    }
498
499    #[tokio::test]
500    async fn losing_eligibility_blocks_a_row_and_regaining_it_unblocks_it() {
501        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
502        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
503        link_student_number(tx.as_mut(), user).await;
504        recompute(tx.as_mut(), &fixture).await;
505        assert_eq!(
506            state(tx.as_mut(), &fixture).await,
507            CreditRegistrationState::ReadyToSubmit
508        );
509
510        crate::course_module_completions::update_needs_to_be_reviewed(
511            tx.as_mut(),
512            fixture.completion,
513            true,
514        )
515        .await
516        .unwrap();
517        recompute(tx.as_mut(), &fixture).await;
518        assert_eq!(
519            state(tx.as_mut(), &fixture).await,
520            CreditRegistrationState::Blocked
521        );
522
523        crate::course_module_completions::update_needs_to_be_reviewed(
524            tx.as_mut(),
525            fixture.completion,
526            false,
527        )
528        .await
529        .unwrap();
530        recompute(tx.as_mut(), &fixture).await;
531        assert_eq!(
532            state(tx.as_mut(), &fixture).await,
533            CreditRegistrationState::ReadyToSubmit
534        );
535    }
536
537    #[tokio::test]
538    async fn a_deleted_completion_cancels_a_row_that_was_never_sent() {
539        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
540        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
541        crate::course_module_completions::delete(tx.as_mut(), fixture.completion)
542            .await
543            .unwrap();
544
545        recompute(tx.as_mut(), &fixture).await;
546        assert_eq!(
547            state(tx.as_mut(), &fixture).await,
548            CreditRegistrationState::Cancelled
549        );
550    }
551
552    #[tokio::test]
553    async fn unlinking_the_student_number_sends_a_queued_row_back_to_wait_for_one() {
554        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
555        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
556        link_student_number(tx.as_mut(), user).await;
557        recompute(tx.as_mut(), &fixture).await;
558        assert_eq!(
559            state(tx.as_mut(), &fixture).await,
560            CreditRegistrationState::ReadyToSubmit
561        );
562
563        let linked = crate::verified_student_numbers::get_by_user_id(tx.as_mut(), user)
564            .await
565            .unwrap()
566            .expect("a linked number");
567        crate::verified_student_numbers::soft_delete(tx.as_mut(), linked.id)
568            .await
569            .unwrap();
570        recompute(tx.as_mut(), &fixture).await;
571        assert_eq!(
572            state(tx.as_mut(), &fixture).await,
573            CreditRegistrationState::Pending
574        );
575    }
576
577    #[tokio::test]
578    async fn a_retryable_row_resumes_where_it_had_got_to_once_its_backoff_elapses() {
579        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
580        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
581        link_student_number(tx.as_mut(), user).await;
582        transition(
583            tx.as_mut(),
584            fixture.registration,
585            &Transition::planted(CreditRegistrationState::FailedRetryable),
586        )
587        .await
588        .unwrap();
589        crate::credit_registrations::schedule_next_attempt(
590            tx.as_mut(),
591            fixture.registration,
592            Utc::now() + chrono::Duration::hours(1),
593        )
594        .await
595        .unwrap();
596
597        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
598
599        crate::credit_registrations::schedule_next_attempt(
600            tx.as_mut(),
601            fixture.registration,
602            Utc::now() - chrono::Duration::seconds(1),
603        )
604        .await
605        .unwrap();
606        recompute(tx.as_mut(), &fixture).await;
607        assert_eq!(
608            state(tx.as_mut(), &fixture).await,
609            CreditRegistrationState::ReadyToSubmit
610        );
611    }
612
613    #[tokio::test]
614    async fn a_row_that_kept_failing_for_a_week_becomes_a_support_case() {
615        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
616        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
617        link_student_number(tx.as_mut(), user).await;
618        transition(
619            tx.as_mut(),
620            fixture.registration,
621            &Transition::planted(CreditRegistrationState::FailedRetryable),
622        )
623        .await
624        .unwrap();
625        first_failed_long_ago(tx.as_mut(), fixture.registration).await;
626
627        recompute(tx.as_mut(), &fixture).await;
628        let row = get_by_id(tx.as_mut(), fixture.registration).await.unwrap();
629        assert_eq!(row.state, CreditRegistrationState::FailedPermanent);
630        assert_eq!(
631            row.error_code,
632            Some(CreditRegistrationErrorCode::RetryWindowExpired)
633        );
634        assert!(row.needs_admin_attention);
635    }
636
637    #[tokio::test]
638    async fn a_row_with_no_usable_enrolment_is_looked_at_again_when_its_recheck_falls_due() {
639        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
640        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
641        link_student_number(tx.as_mut(), user).await;
642        transition(
643            tx.as_mut(),
644            fixture.registration,
645            &Transition::planted(CreditRegistrationState::NoUsableEnrolment),
646        )
647        .await
648        .unwrap();
649        crate::credit_registrations::schedule_next_attempt(
650            tx.as_mut(),
651            fixture.registration,
652            Utc::now() + chrono::Duration::hours(24),
653        )
654        .await
655        .unwrap();
656
657        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
658
659        crate::credit_registrations::schedule_next_attempt(
660            tx.as_mut(),
661            fixture.registration,
662            Utc::now() - chrono::Duration::seconds(1),
663        )
664        .await
665        .unwrap();
666        recompute(tx.as_mut(), &fixture).await;
667        assert_eq!(
668            state(tx.as_mut(), &fixture).await,
669            CreditRegistrationState::ReadyToSubmit
670        );
671    }
672
673    /// Its payload is already frozen, so resolving the enrolment again would be a loop.
674    #[tokio::test]
675    async fn a_row_queued_for_import_is_left_where_it_is() {
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        link_student_number(tx.as_mut(), user).await;
679        transition(
680            tx.as_mut(),
681            fixture.registration,
682            &Transition::planted(CreditRegistrationState::CheckingEnrolment),
683        )
684        .await
685        .unwrap();
686
687        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
688        assert_eq!(
689            state(tx.as_mut(), &fixture).await,
690            CreditRegistrationState::CheckingEnrolment
691        );
692    }
693
694    #[tokio::test]
695    async fn a_paused_module_stops_the_recompute_without_rewriting_anything() {
696        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
697        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
698        pause_module(tx.as_mut(), course_module.id, user).await;
699
700        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
701        assert_eq!(
702            state(tx.as_mut(), &fixture).await,
703            CreditRegistrationState::Pending
704        );
705    }
706
707    #[tokio::test]
708    async fn a_terminal_row_is_never_recomputed() {
709        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
710        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
711        transition(
712            tx.as_mut(),
713            fixture.registration,
714            &Transition::planted(CreditRegistrationState::Registered),
715        )
716        .await
717        .unwrap();
718
719        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
720        assert_eq!(
721            state(tx.as_mut(), &fixture).await,
722            CreditRegistrationState::Registered
723        );
724    }
725
726    #[tokio::test]
727    async fn a_reversed_registration_waits_for_a_human() {
728        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
729        let fixture = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
730        transition(
731            tx.as_mut(),
732            fixture.registration,
733            &Transition::planted(CreditRegistrationState::Misregistered),
734        )
735        .await
736        .unwrap();
737
738        assert_eq!(recompute(tx.as_mut(), &fixture).await, 0);
739        assert_eq!(
740            state(tx.as_mut(), &fixture).await,
741            CreditRegistrationState::Misregistered
742        );
743    }
744
745    #[test]
746    fn a_retry_resumes_where_the_row_had_got_to() {
747        assert_eq!(
748            resume_state(false, false, false),
749            CreditRegistrationState::ReadyToSubmit
750        );
751        assert_eq!(
752            resume_state(false, true, false),
753            CreditRegistrationState::CheckingEnrolment
754        );
755        assert_eq!(
756            resume_state(true, true, false),
757            CreditRegistrationState::AwaitingVerification
758        );
759    }
760
761    /// Resuming at `checking_enrolment` here would import the number the account no longer holds.
762    #[test]
763    fn a_retry_whose_frozen_identity_went_stale_resolves_the_enrolment_again() {
764        assert_eq!(
765            resume_state(false, true, true),
766            CreditRegistrationState::ReadyToSubmit
767        );
768    }
769
770    #[tokio::test]
771    async fn a_scoped_recompute_leaves_another_students_row_alone() {
772        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
773        let mine = fixture(tx.as_mut(), user, course, instance.id, course_module.id).await;
774        link_student_number(tx.as_mut(), user).await;
775        insert_data!(tx: tx; user: other_user);
776        let theirs = fixture(
777            tx.as_mut(),
778            other_user,
779            course,
780            instance.id,
781            course_module.id,
782        )
783        .await;
784
785        assert_eq!(
786            recompute_preconditions(
787                tx.as_mut(),
788                &RegistrationScope {
789                    user_id: Some(user),
790                    ..RegistrationScope::default()
791                },
792                PRECONDITIONS_LIMIT
793            )
794            .await
795            .unwrap(),
796            1
797        );
798        assert_eq!(
799            state(tx.as_mut(), &mine).await,
800            CreditRegistrationState::ReadyToSubmit
801        );
802        assert_eq!(
803            state(tx.as_mut(), &theirs).await,
804            CreditRegistrationState::Pending
805        );
806    }
807}