1use 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
21pub 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 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 check_start: Option<CheckStartFacts>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum Target {
47 Stay,
48 Move(CreditRegistrationState),
49 Resume,
51}
52
53fn 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 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 State::FailedRetryable if !facts.has_verified_student_number => {
88 Target::Move(State::Pending)
89 }
90 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 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 State::NoUsableEnrolment => Target::Stay,
109 State::CheckingEnrolment if row.frozen_identity_stale => Target::Move(State::ReadyToSubmit),
112 State::CheckingEnrolment => Target::Stay,
115 State::ResolvingEnrolment if elapsed(row.state_entered_at, RESOLVING_RECOVERY_GRACE) => {
117 Target::Move(State::ReadyToSubmit)
118 }
119 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
133fn 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
162pub 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 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 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
245fn 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 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 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
300async 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 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 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 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 #[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 #[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}