1use chrono::NaiveDate;
7use utoipa::ToSchema;
8
9use crate::credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent};
10use crate::prelude::*;
11
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
14#[sqlx(type_name = "credit_registration_state", rename_all = "snake_case")]
15#[serde(rename_all = "snake_case")]
16pub enum CreditRegistrationState {
17 PendingPrerequisites,
18 PendingConsent,
19 PendingStudentNumber,
20 ReadyToSubmit,
21 CheckingEnrolment,
22 NoUsableEnrolment,
23 Submitting,
24 SubmissionUncertain,
25 AwaitingVerification,
26 Registered,
27 Duplicate,
28 NotImproved,
29 Misregistered,
30 FailedRetryable,
31 FailedPermanent,
32 Blocked,
33 Cancelled,
34 AbandonedByConsentWithdrawal,
35}
36
37impl CreditRegistrationState {
38 pub fn is_terminal(self) -> bool {
41 matches!(
42 self,
43 Self::Registered
44 | Self::Duplicate
45 | Self::NotImproved
46 | Self::FailedPermanent
47 | Self::Cancelled
48 | Self::AbandonedByConsentWithdrawal
49 )
50 }
51
52 pub fn is_failure(self) -> bool {
54 matches!(self, Self::FailedRetryable | Self::FailedPermanent)
55 }
56
57 pub fn is_success(self) -> bool {
59 matches!(self, Self::Registered | Self::Duplicate | Self::NotImproved)
60 }
61}
62
63#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
65#[sqlx(
66 type_name = "credit_registration_error_code",
67 rename_all = "snake_case"
68)]
69#[serde(rename_all = "snake_case")]
70pub enum CreditRegistrationErrorCode {
71 PersonNotFound,
72 CourseCodeNotFound,
73 EnrolmentNotFound,
74 EnrolmentNotAccepted,
75 InvalidGradeForGradeScale,
76 CourseNotAllowed,
77 InvalidCredits,
78 StudyRightNotValid,
79 AcceptorNotFound,
80 SisuValidationFailed,
81 SisuTimeout,
82 SisuTemporarilyUnavailable,
83 Misregistered,
84 Unauthorized,
85 MalformedRequest,
86 TransportError,
87 UnexpectedResponse,
88 NoGradeScaleMapping,
89 MissingUhCourseCode,
90 MissingEctsCredits,
91 RetryWindowExpired,
92 Unknown,
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96pub struct CreditRegistration {
97 pub id: Uuid,
98 pub created_at: DateTime<Utc>,
99 pub updated_at: DateTime<Utc>,
100 pub deleted_at: Option<DateTime<Utc>>,
101 pub course_module_completion_id: Uuid,
102 pub user_id: Uuid,
103 pub course_id: Uuid,
104 pub course_module_id: Uuid,
105 pub course_instance_id: Uuid,
106 pub state: CreditRegistrationState,
107 pub state_entered_at: DateTime<Utc>,
108 pub error_code: Option<CreditRegistrationErrorCode>,
109 pub error_message: Option<String>,
110 pub needs_admin_attention: bool,
111 pub enrolment_banner_dismissed_at: Option<DateTime<Utc>>,
112 pub student_number: Option<String>,
113 pub sisu_person_id: Option<String>,
114 pub uh_course_code: Option<String>,
115 pub selected_enrolment_id: Option<String>,
116 pub selected_enrolment_kind: Option<String>,
117 pub selected_enrolment_realisation_id: Option<String>,
118 pub attainment_date: Option<NaiveDate>,
119 pub attainment_language: Option<String>,
120 pub grade_scale_id: Option<String>,
121 pub grade_id: Option<String>,
122 pub credits: Option<f32>,
123 pub request_item_id: String,
124 pub submitted_attainment_id: Option<String>,
125 pub submitted_attainment_type: Option<String>,
126 pub sisu_attainment_id: Option<String>,
127 pub sisu_attainment_type: Option<String>,
128 pub submit_retry_count: i32,
129 pub verify_attempt_count: i32,
130 pub next_attempt_at: DateTime<Utc>,
131 pub first_failed_at: Option<DateTime<Utc>>,
132 pub last_attempt_at: Option<DateTime<Utc>>,
133 pub attempt_number: i32,
134 pub superseded_by_id: Option<Uuid>,
135 pub superseded_at: Option<DateTime<Utc>>,
136 pub enrolment_checked_at: Option<DateTime<Utc>>,
137 pub submitted_at: Option<DateTime<Utc>>,
138 pub registered_at: Option<DateTime<Utc>>,
139 pub terminal_at: Option<DateTime<Utc>>,
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct NewCreditRegistration {
144 pub course_module_completion_id: Uuid,
145 pub user_id: Uuid,
146 pub course_id: Uuid,
147 pub course_module_id: Uuid,
148 pub course_instance_id: Uuid,
149 pub attempt_number: i32,
150}
151
152pub fn import_request_item_id(registration_id: Uuid) -> String {
155 format!("cr-{registration_id}")
156}
157
158pub fn verify_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
160 format!("vf-{registration_id}-{verify_attempt_count}")
161}
162
163pub async fn insert(
166 conn: &mut PgConnection,
167 pkey_policy: PKeyPolicy<Uuid>,
168 new: &NewCreditRegistration,
169 event_message: Option<&str>,
170) -> ModelResult<Uuid> {
171 let id = pkey_policy.into_uuid();
172 let mut tx = conn.begin().await?;
173 sqlx::query!(
174 r#"
175INSERT INTO credit_registrations (
176 id,
177 course_module_completion_id,
178 user_id,
179 course_id,
180 course_module_id,
181 course_instance_id,
182 attempt_number,
183 request_item_id
184 )
185VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
186 "#,
187 id,
188 new.course_module_completion_id,
189 new.user_id,
190 new.course_id,
191 new.course_module_id,
192 new.course_instance_id,
193 new.attempt_number,
194 import_request_item_id(id),
195 )
196 .execute(&mut *tx)
197 .await?;
198
199 crate::credit_registration_events::insert(
200 &mut tx,
201 &NewCreditRegistrationEvent {
202 message: event_message.map(str::to_string),
203 ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
204 },
205 )
206 .await?;
207
208 tx.commit().await?;
209 Ok(id)
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub struct Transition {
214 pub to_state: CreditRegistrationState,
215 pub error_code: Option<CreditRegistrationErrorCode>,
216 pub error_message: Option<String>,
218 pub needs_admin_attention: Option<bool>,
219 pub event_kind: CreditRegistrationEventKind,
220 pub event_message: Option<String>,
221 pub actor_user_id: Option<Uuid>,
222 pub suotar_api_call_id: Option<Uuid>,
223 pub event_details: Option<serde_json::Value>,
225}
226
227impl Transition {
228 pub fn to(to_state: CreditRegistrationState) -> Self {
229 Self {
230 to_state,
231 error_code: None,
232 error_message: None,
233 needs_admin_attention: None,
234 event_kind: CreditRegistrationEventKind::StateChanged,
235 event_message: None,
236 actor_user_id: None,
237 suotar_api_call_id: None,
238 event_details: None,
239 }
240 }
241}
242
243pub async fn transition(
251 conn: &mut PgConnection,
252 id: Uuid,
253 transition: &Transition,
254) -> ModelResult<CreditRegistration> {
255 let mut tx = conn.begin().await?;
256
257 let before = sqlx::query_as!(
258 CreditRegistration,
259 r#"
260SELECT *
261FROM credit_registrations
262WHERE id = $1
263 AND deleted_at IS NULL
264FOR UPDATE
265 "#,
266 id
267 )
268 .fetch_one(&mut *tx)
269 .await?;
270
271 let to_state = transition.to_state;
272 let after = sqlx::query_as!(
273 CreditRegistration,
274 r#"
275UPDATE credit_registrations
276SET state = $2::credit_registration_state,
277 -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
278 -- one transaction would share an instant and the timeline would lose their order.
279 state_entered_at = clock_timestamp(),
280 error_code = $3,
281 error_message = $4,
282 needs_admin_attention = COALESCE($5, needs_admin_attention),
283 -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
284 terminal_at = CASE
285 WHEN $6 THEN COALESCE(terminal_at, now())
286 ELSE NULL
287 END,
288 first_failed_at = CASE
289 WHEN $7 THEN COALESCE(first_failed_at, now())
290 ELSE first_failed_at
291 END,
292 registered_at = CASE
293 WHEN $2::credit_registration_state = 'registered' THEN COALESCE(registered_at, now())
294 ELSE registered_at
295 END,
296 submitted_at = CASE
297 WHEN $2::credit_registration_state = 'submitting' THEN now()
298 ELSE submitted_at
299 END,
300 enrolment_checked_at = CASE
301 WHEN state = 'checking_enrolment'
302 AND $2::credit_registration_state <> 'checking_enrolment' THEN now()
303 ELSE enrolment_checked_at
304 END,
305 enrolment_banner_dismissed_at = CASE
306 WHEN $2::credit_registration_state = 'no_usable_enrolment' THEN NULL
307 ELSE enrolment_banner_dismissed_at
308 END
309WHERE id = $1
310 AND deleted_at IS NULL
311RETURNING *
312 "#,
313 id,
314 to_state as CreditRegistrationState,
315 transition.error_code as Option<CreditRegistrationErrorCode>,
316 transition.error_message,
317 transition.needs_admin_attention,
318 to_state.is_terminal(),
319 to_state.is_failure(),
320 )
321 .fetch_one(&mut *tx)
322 .await?;
323
324 crate::credit_registration_events::insert(
325 &mut tx,
326 &NewCreditRegistrationEvent {
327 credit_registration_id: id,
328 kind: transition.event_kind,
329 from_state: Some(before.state),
330 to_state: Some(to_state),
331 error_code: transition.error_code,
332 message: transition.event_message.clone(),
333 suotar_api_call_id: transition.suotar_api_call_id,
334 actor_user_id: transition.actor_user_id,
335 details: transition.event_details.clone(),
336 },
337 )
338 .await?;
339
340 tx.commit().await?;
341 Ok(after)
342}
343
344pub async fn claim_due(
350 conn: &mut PgConnection,
351 states: &[CreditRegistrationState],
352 limit: i64,
353) -> ModelResult<Vec<CreditRegistration>> {
354 let res = sqlx::query_as!(
355 CreditRegistration,
356 r#"
357WITH due AS (
358 SELECT cr.id
359 FROM credit_registrations cr
360 LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cr.course_module_id
361 AND c.deleted_at IS NULL
362 WHERE cr.deleted_at IS NULL
363 AND cr.superseded_by_id IS NULL
364 AND cr.state = ANY($1::credit_registration_state [])
365 AND cr.next_attempt_at <= now()
366 AND c.paused_at IS NULL
367 ORDER BY cr.next_attempt_at
368 FOR UPDATE OF cr SKIP LOCKED
369 LIMIT $2
370)
371UPDATE credit_registrations cr
372SET last_attempt_at = now()
373FROM due
374WHERE cr.id = due.id
375RETURNING cr.*
376 "#,
377 states as &[CreditRegistrationState],
378 limit,
379 )
380 .fetch_all(conn)
381 .await?;
382 Ok(res)
383}
384
385pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
386 let res = sqlx::query_as!(
387 CreditRegistration,
388 r#"
389SELECT *
390FROM credit_registrations
391WHERE id = $1
392 AND deleted_at IS NULL
393 "#,
394 id
395 )
396 .fetch_one(conn)
397 .await?;
398 Ok(res)
399}
400
401pub async fn get_by_ids(
402 conn: &mut PgConnection,
403 ids: &[Uuid],
404) -> ModelResult<Vec<CreditRegistration>> {
405 let res = sqlx::query_as!(
406 CreditRegistration,
407 r#"
408SELECT *
409FROM credit_registrations
410WHERE id = ANY($1::uuid [])
411 AND deleted_at IS NULL
412 "#,
413 ids
414 )
415 .fetch_all(conn)
416 .await?;
417 Ok(res)
418}
419
420pub async fn get_live_by_completion_id(
422 conn: &mut PgConnection,
423 course_module_completion_id: Uuid,
424) -> ModelResult<Option<CreditRegistration>> {
425 let res = sqlx::query_as!(
426 CreditRegistration,
427 r#"
428SELECT *
429FROM credit_registrations
430WHERE course_module_completion_id = $1
431 AND superseded_by_id IS NULL
432 AND deleted_at IS NULL
433 "#,
434 course_module_completion_id
435 )
436 .fetch_optional(conn)
437 .await?;
438 Ok(res)
439}
440
441pub async fn get_all_attempts_by_completion_id(
444 conn: &mut PgConnection,
445 course_module_completion_id: Uuid,
446) -> ModelResult<Vec<CreditRegistration>> {
447 let res = sqlx::query_as!(
448 CreditRegistration,
449 r#"
450SELECT *
451FROM credit_registrations
452WHERE course_module_completion_id = $1
453 AND deleted_at IS NULL
454ORDER BY attempt_number DESC
455 "#,
456 course_module_completion_id
457 )
458 .fetch_all(conn)
459 .await?;
460 Ok(res)
461}
462
463pub async fn get_by_user_id(
464 conn: &mut PgConnection,
465 user_id: Uuid,
466) -> ModelResult<Vec<CreditRegistration>> {
467 let res = sqlx::query_as!(
468 CreditRegistration,
469 r#"
470SELECT *
471FROM credit_registrations
472WHERE user_id = $1
473 AND deleted_at IS NULL
474ORDER BY created_at DESC
475 "#,
476 user_id
477 )
478 .fetch_all(conn)
479 .await?;
480 Ok(res)
481}
482
483pub async fn get_by_course_id(
484 conn: &mut PgConnection,
485 course_id: Uuid,
486) -> ModelResult<Vec<CreditRegistration>> {
487 let res = sqlx::query_as!(
488 CreditRegistration,
489 r#"
490SELECT *
491FROM credit_registrations
492WHERE course_id = $1
493 AND deleted_at IS NULL
494ORDER BY created_at DESC
495 "#,
496 course_id
497 )
498 .fetch_all(conn)
499 .await?;
500 Ok(res)
501}
502
503#[derive(Debug, Clone, PartialEq)]
506pub struct PayloadSnapshot {
507 pub student_number: String,
508 pub sisu_person_id: String,
509 pub uh_course_code: String,
510 pub selected_enrolment_id: Option<String>,
511 pub selected_enrolment_kind: Option<String>,
512 pub selected_enrolment_realisation_id: Option<String>,
513 pub attainment_date: NaiveDate,
514 pub attainment_language: String,
515 pub grade_scale_id: String,
516 pub grade_id: String,
517 pub credits: f32,
518}
519
520pub async fn set_payload_snapshot(
521 conn: &mut PgConnection,
522 id: Uuid,
523 snapshot: &PayloadSnapshot,
524) -> ModelResult<()> {
525 sqlx::query!(
526 r#"
527UPDATE credit_registrations
528SET student_number = $2,
529 sisu_person_id = $3,
530 uh_course_code = $4,
531 selected_enrolment_id = $5,
532 selected_enrolment_kind = $6,
533 selected_enrolment_realisation_id = $7,
534 attainment_date = $8,
535 attainment_language = $9,
536 grade_scale_id = $10,
537 grade_id = $11,
538 credits = $12
539WHERE id = $1
540 AND deleted_at IS NULL
541 "#,
542 id,
543 snapshot.student_number,
544 snapshot.sisu_person_id,
545 snapshot.uh_course_code,
546 snapshot.selected_enrolment_id,
547 snapshot.selected_enrolment_kind,
548 snapshot.selected_enrolment_realisation_id,
549 snapshot.attainment_date,
550 snapshot.attainment_language,
551 snapshot.grade_scale_id,
552 snapshot.grade_id,
553 snapshot.credits,
554 )
555 .execute(conn)
556 .await?;
557 Ok(())
558}
559
560pub async fn set_submitted_attainment(
561 conn: &mut PgConnection,
562 id: Uuid,
563 submitted_attainment_id: &str,
564 submitted_attainment_type: Option<&str>,
565) -> ModelResult<()> {
566 sqlx::query!(
567 r#"
568UPDATE credit_registrations
569SET submitted_attainment_id = $2,
570 submitted_attainment_type = $3
571WHERE id = $1
572 AND deleted_at IS NULL
573 "#,
574 id,
575 submitted_attainment_id,
576 submitted_attainment_type,
577 )
578 .execute(conn)
579 .await?;
580 Ok(())
581}
582
583pub async fn set_sisu_attainment(
584 conn: &mut PgConnection,
585 id: Uuid,
586 sisu_attainment_id: &str,
587 sisu_attainment_type: Option<&str>,
588) -> ModelResult<()> {
589 sqlx::query!(
590 r#"
591UPDATE credit_registrations
592SET sisu_attainment_id = $2,
593 sisu_attainment_type = $3
594WHERE id = $1
595 AND deleted_at IS NULL
596 "#,
597 id,
598 sisu_attainment_id,
599 sisu_attainment_type,
600 )
601 .execute(conn)
602 .await?;
603 Ok(())
604}
605
606pub async fn schedule_next_attempt(
611 conn: &mut PgConnection,
612 id: Uuid,
613 next_attempt_at: DateTime<Utc>,
614) -> ModelResult<()> {
615 sqlx::query!(
616 r#"
617UPDATE credit_registrations
618SET next_attempt_at = $2
619WHERE id = $1
620 AND deleted_at IS NULL
621 "#,
622 id,
623 next_attempt_at,
624 )
625 .execute(conn)
626 .await?;
627 Ok(())
628}
629
630pub async fn increment_submit_retry_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
631 let res = sqlx::query!(
632 r#"
633UPDATE credit_registrations
634SET submit_retry_count = submit_retry_count + 1
635WHERE id = $1
636 AND deleted_at IS NULL
637RETURNING submit_retry_count
638 "#,
639 id
640 )
641 .fetch_one(conn)
642 .await?;
643 Ok(res.submit_retry_count)
644}
645
646pub async fn increment_verify_attempt_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
647 let res = sqlx::query!(
648 r#"
649UPDATE credit_registrations
650SET verify_attempt_count = verify_attempt_count + 1
651WHERE id = $1
652 AND deleted_at IS NULL
653RETURNING verify_attempt_count
654 "#,
655 id
656 )
657 .fetch_one(conn)
658 .await?;
659 Ok(res.verify_attempt_count)
660}
661
662pub async fn set_needs_admin_attention(
663 conn: &mut PgConnection,
664 id: Uuid,
665 needs_admin_attention: bool,
666) -> ModelResult<()> {
667 sqlx::query!(
668 r#"
669UPDATE credit_registrations
670SET needs_admin_attention = $2
671WHERE id = $1
672 AND deleted_at IS NULL
673 "#,
674 id,
675 needs_admin_attention,
676 )
677 .execute(conn)
678 .await?;
679 Ok(())
680}
681
682pub async fn dismiss_enrolment_banner(
684 conn: &mut PgConnection,
685 id: Uuid,
686 user_id: Uuid,
687) -> ModelResult<()> {
688 sqlx::query!(
689 r#"
690UPDATE credit_registrations
691SET enrolment_banner_dismissed_at = now()
692WHERE id = $1
693 AND user_id = $2
694 AND deleted_at IS NULL
695 "#,
696 id,
697 user_id,
698 )
699 .execute(conn)
700 .await?;
701 Ok(())
702}
703
704pub async fn mark_superseded(
707 conn: &mut PgConnection,
708 id: Uuid,
709 superseded_by_id: Uuid,
710) -> ModelResult<()> {
711 sqlx::query!(
712 r#"
713UPDATE credit_registrations
714SET superseded_by_id = $2,
715 superseded_at = now()
716WHERE id = $1
717 AND deleted_at IS NULL
718 "#,
719 id,
720 superseded_by_id,
721 )
722 .execute(conn)
723 .await?;
724 Ok(())
725}
726
727pub async fn count_by_state(
729 conn: &mut PgConnection,
730) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
731 let rows = sqlx::query!(
732 r#"
733SELECT state AS "state: CreditRegistrationState",
734 COUNT(*) AS "count!"
735FROM credit_registrations
736WHERE deleted_at IS NULL
737GROUP BY state
738 "#,
739 )
740 .fetch_all(conn)
741 .await?;
742 Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::course_module_completions::{
749 CourseModuleCompletionGranter, NewCourseModuleCompletion,
750 };
751 use crate::credit_registration_events::CreditRegistrationEventKind;
752 use crate::test_helper::*;
753
754 async fn insert_registration(
755 conn: &mut PgConnection,
756 user: Uuid,
757 course: Uuid,
758 course_instance: Uuid,
759 course_module: Uuid,
760 ) -> Uuid {
761 let completion = crate::course_module_completions::insert(
762 conn,
763 PKeyPolicy::Generate,
764 &NewCourseModuleCompletion {
765 course_id: course,
766 course_module_id: course_module,
767 user_id: user,
768 completion_date: Utc::now(),
769 completion_registration_attempt_date: None,
770 completion_language: "en".to_string(),
771 eligible_for_ects: true,
772 email: "student@example.com".to_string(),
773 grade: Some(4),
774 passed: true,
775 },
776 CourseModuleCompletionGranter::Automatic,
777 )
778 .await
779 .unwrap();
780
781 insert(
782 conn,
783 PKeyPolicy::Generate,
784 &NewCreditRegistration {
785 course_module_completion_id: completion.id,
786 user_id: user,
787 course_id: course,
788 course_module_id: course_module,
789 course_instance_id: course_instance,
790 attempt_number: 1,
791 },
792 None,
793 )
794 .await
795 .unwrap()
796 }
797
798 #[tokio::test]
799 async fn transition_stamps_state_entered_at_and_writes_an_event() {
800 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
801 let id =
802 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
803 let before = get_by_id(tx.as_mut(), id).await.unwrap();
804
805 let after = transition(
806 tx.as_mut(),
807 id,
808 &Transition::to(CreditRegistrationState::PendingConsent),
809 )
810 .await
811 .unwrap();
812
813 assert_eq!(after.state, CreditRegistrationState::PendingConsent);
814 assert!(after.state_entered_at > before.state_entered_at);
815
816 let events = crate::credit_registration_events::get_by_registration_id(tx.as_mut(), id)
817 .await
818 .unwrap();
819 assert_eq!(events.len(), 2);
821 assert_eq!(events[1].kind, CreditRegistrationEventKind::Created);
822 assert_eq!(events[0].kind, CreditRegistrationEventKind::StateChanged);
823 assert_eq!(
824 events[0].from_state,
825 Some(CreditRegistrationState::PendingPrerequisites)
826 );
827 assert_eq!(
828 events[0].to_state,
829 Some(CreditRegistrationState::PendingConsent)
830 );
831 }
832
833 #[tokio::test]
834 async fn consecutive_state_changes_stay_ordered_inside_one_transaction() {
835 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
836 let id =
837 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
838
839 let first = transition(
840 tx.as_mut(),
841 id,
842 &Transition::to(CreditRegistrationState::PendingConsent),
843 )
844 .await
845 .unwrap();
846 let second = transition(
847 tx.as_mut(),
848 id,
849 &Transition::to(CreditRegistrationState::PendingStudentNumber),
850 )
851 .await
852 .unwrap();
853 assert!(second.state_entered_at > first.state_entered_at);
854 }
855
856 #[tokio::test]
857 async fn transition_stamps_lifecycle_timestamps() {
858 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
859 let id =
860 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
861
862 let checking = transition(
863 tx.as_mut(),
864 id,
865 &Transition::to(CreditRegistrationState::CheckingEnrolment),
866 )
867 .await
868 .unwrap();
869 assert!(checking.enrolment_checked_at.is_none());
870 assert!(checking.submitted_at.is_none());
871 assert!(checking.terminal_at.is_none());
872
873 let submitting = transition(
875 tx.as_mut(),
876 id,
877 &Transition::to(CreditRegistrationState::Submitting),
878 )
879 .await
880 .unwrap();
881 assert!(submitting.enrolment_checked_at.is_some());
882 assert!(submitting.submitted_at.is_some());
883 assert!(submitting.registered_at.is_none());
884 assert!(submitting.terminal_at.is_none());
885
886 let registered = transition(
887 tx.as_mut(),
888 id,
889 &Transition::to(CreditRegistrationState::Registered),
890 )
891 .await
892 .unwrap();
893 assert!(registered.registered_at.is_some());
894 assert!(registered.terminal_at.is_some());
895 assert_eq!(registered.submitted_at, submitting.submitted_at);
896 assert_eq!(
897 registered.enrolment_checked_at,
898 submitting.enrolment_checked_at
899 );
900 }
901
902 #[tokio::test]
903 async fn terminal_at_holds_between_terminal_states_and_clears_on_a_retry() {
904 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
905 let id =
906 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
907
908 let first = transition(
909 tx.as_mut(),
910 id,
911 &Transition::to(CreditRegistrationState::Cancelled),
912 )
913 .await
914 .unwrap();
915 let terminal_at = first.terminal_at.unwrap();
916
917 let second = transition(
918 tx.as_mut(),
919 id,
920 &Transition::to(CreditRegistrationState::FailedPermanent),
921 )
922 .await
923 .unwrap();
924 assert_eq!(second.terminal_at, Some(terminal_at));
925
926 let retried = transition(
927 tx.as_mut(),
928 id,
929 &Transition::to(CreditRegistrationState::ReadyToSubmit),
930 )
931 .await
932 .unwrap();
933 assert_eq!(retried.terminal_at, None);
934 }
935
936 #[tokio::test]
937 async fn entering_no_usable_enrolment_clears_a_dismissed_banner() {
938 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
939 let id =
940 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
941
942 transition(
943 tx.as_mut(),
944 id,
945 &Transition::to(CreditRegistrationState::NoUsableEnrolment),
946 )
947 .await
948 .unwrap();
949 dismiss_enrolment_banner(tx.as_mut(), id, user)
950 .await
951 .unwrap();
952 assert!(
953 get_by_id(tx.as_mut(), id)
954 .await
955 .unwrap()
956 .enrolment_banner_dismissed_at
957 .is_some()
958 );
959
960 transition(
961 tx.as_mut(),
962 id,
963 &Transition::to(CreditRegistrationState::ReadyToSubmit),
964 )
965 .await
966 .unwrap();
967 assert!(
969 get_by_id(tx.as_mut(), id)
970 .await
971 .unwrap()
972 .enrolment_banner_dismissed_at
973 .is_some()
974 );
975
976 let back = transition(
977 tx.as_mut(),
978 id,
979 &Transition::to(CreditRegistrationState::NoUsableEnrolment),
980 )
981 .await
982 .unwrap();
983 assert_eq!(back.enrolment_banner_dismissed_at, None);
984 }
985
986 #[tokio::test]
987 async fn transition_carries_the_error_and_leaves_the_admin_flag_alone_unless_asked() {
988 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
989 let id =
990 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
991 set_needs_admin_attention(tx.as_mut(), id, true)
992 .await
993 .unwrap();
994
995 let failed = transition(
996 tx.as_mut(),
997 id,
998 &Transition {
999 error_code: Some(CreditRegistrationErrorCode::EnrolmentNotFound),
1000 error_message: Some("no accepted enrolment".to_string()),
1001 ..Transition::to(CreditRegistrationState::FailedPermanent)
1002 },
1003 )
1004 .await
1005 .unwrap();
1006 assert_eq!(
1007 failed.error_code,
1008 Some(CreditRegistrationErrorCode::EnrolmentNotFound)
1009 );
1010 assert!(failed.needs_admin_attention);
1011
1012 let resolved = transition(
1013 tx.as_mut(),
1014 id,
1015 &Transition {
1016 needs_admin_attention: Some(false),
1017 ..Transition::to(CreditRegistrationState::ReadyToSubmit)
1018 },
1019 )
1020 .await
1021 .unwrap();
1022 assert!(!resolved.needs_admin_attention);
1023 assert_eq!(resolved.error_code, None);
1024 }
1025}