1use 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
13pub const PRECONDITIONS_LIMIT: i64 = 500;
15
16#[derive(Debug, Clone, PartialEq)]
17struct PendingMove {
18 id: Uuid,
19 state: CreditRegistrationState,
20 target: Option<CreditRegistrationState>,
22 preconditions: PendingPreconditions,
24 has_submitted_attainment: bool,
25 has_payload_snapshot: bool,
26 frozen_identity_stale: bool,
27}
28
29fn 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
49pub 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
79fn transition_for(pending: &PendingMove, target: CreditRegistrationState) -> Transition {
82 use CreditRegistrationState as State;
83 let base = Transition {
84 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 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 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
148async 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 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 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 #[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 #[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}