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