1use anyhow::Result;
11use chrono::{Duration, Utc};
12use headless_lms_base::config::{
13 ApplicationConfiguration, SuotarConfiguration, bool_env_false_by_default,
14};
15use headless_lms_models::{
16 PKeyPolicy, course_instance_enrollments,
17 course_module_completions::{self, NewCourseModuleCompletionSeed},
18 credit_registration_account_linking_emails::{self, NewAccountLinkingEmail},
19 credit_registration_admin_actions::{
20 self, COURSE_TEACHER_ROLE, CreditRegistrationAdminAction,
21 CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE, NewCreditRegistrationAdminAction,
22 },
23 credit_registrations::{
24 self, CreditRegistrationErrorCode, CreditRegistrationState, NewCreditRegistration,
25 PayloadSnapshot, Transition,
26 },
27 roles::UserRole,
28 student_number_verification_tokens::{self, SeedStudentNumberVerificationToken},
29 study_registry_registrars::{self, get_or_create_default_registrar},
30 user_details::{self, EmailVerificationMethod},
31 user_passwords::{hash_password, upsert_user_password},
32 users,
33 verified_student_numbers::{self, NewVerifiedStudentNumber, StudentNumberVerificationMethod},
34};
35use headless_lms_utils::http::REQWEST_CLIENT;
36use secrecy::SecretString;
37use sqlx::{Connection, PgConnection};
38use tracing::info;
39use uuid::Uuid;
40
41use crate::controllers::mock_suotar::fixtures::*;
42use crate::controllers::mock_suotar::ids as mock_ids;
43use crate::controllers::mock_suotar::world::RealisationKind;
44use crate::programs::seed::builder::{
45 chapter::ChapterBuilder,
46 context::SeedContext,
47 course::{CourseBuilder, CourseInstanceConfig},
48 module::{
49 CompletionBuilder, CompletionRegisteredBuilder, CreditRegistrationSeed, ModuleBuilder,
50 },
51 page::PageBuilder,
52};
53use crate::programs::seed::seed_courses::CommonCourseData;
54use crate::programs::seed::seed_helpers::paragraph;
55
56pub const PULL_REGISTRAR_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0008_4a5e_9e6e_c0de00000008);
59pub const PULL_REGISTRAR_SECRET_KEY: &str = "credit-registration-system-tests-pull-registrar";
60
61pub const SUPERSEDED_ATTEMPT_1_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0901_4a5e_9e6e_c0de00000901);
63pub const SUPERSEDED_ATTEMPT_2_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0902_4a5e_9e6e_c0de00000902);
64
65pub const LINKING_TOKEN_VALID: &str = concat!(
70 "11111111-1111-1111-1111-111111111111",
71 "11111111-1111-1111-1111-111111111111",
72 "11111111-1111-1111-1111-111111111111",
73 "11111111-1111-1111-1111-111111111111",
74);
75pub const LINKING_TOKEN_EXPIRED: &str = concat!(
76 "22222222-2222-2222-2222-222222222222",
77 "22222222-2222-2222-2222-222222222222",
78 "22222222-2222-2222-2222-222222222222",
79 "22222222-2222-2222-2222-222222222222",
80);
81pub const LINKING_TOKEN_ALREADY_USED: &str = concat!(
82 "33333333-3333-3333-3333-333333333333",
83 "33333333-3333-3333-3333-333333333333",
84 "33333333-3333-3333-3333-333333333333",
85 "33333333-3333-3333-3333-333333333333",
86);
87pub const LINKING_TOKEN_CONFLICT: &str = concat!(
90 "44444444-4444-4444-4444-444444444444",
91 "44444444-4444-4444-4444-444444444444",
92 "44444444-4444-4444-4444-444444444444",
93 "44444444-4444-4444-4444-444444444444",
94);
95
96struct SeededStudent {
98 user_id: Uuid,
99 email: String,
100}
101
102pub async fn seed_credit_registration(
103 app_config: &ApplicationConfiguration,
104 common_course_data: CommonCourseData,
105) -> Result<Uuid> {
106 let CommonCourseData {
107 db_pool,
108 organization_id: org,
109 teacher_user_id,
110 base_url,
111 ..
112 } = common_course_data;
113
114 let mut conn = db_pool.acquire().await?;
115 let cx = SeedContext {
116 teacher: teacher_user_id,
117 org,
118 base_course_ns: SUOTAR_COURSE_ID,
119 };
120
121 info!("inserting credit registration courses");
122
123 let suotar_instance_id = cx.v5(b"instance:suotar");
124 let (suotar_course, suotar_instance, _) =
125 CourseBuilder::new("Credit registration via Suotar", SUOTAR_COURSE_SLUG)
126 .desc("Fixture course for the credit registration system tests.")
127 .course_id(SUOTAR_COURSE_ID)
128 .role(teacher_user_id, UserRole::Teacher)
129 .instance(instance_config(suotar_instance_id))
130 .module(
131 ModuleBuilder::new()
132 .order(0)
133 .ects(5.0)
134 .uh_course_code(CRS_101.to_string())
135 .credit_registration(credit_registration_config(CRS_101, true))
136 .chapter(
138 ChapterBuilder::new(1, "Registering credits")
139 .opens(Utc::now())
140 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
141 .page(
142 PageBuilder::new("/chapter-1/page-1", "How registration works")
143 .block(paragraph(
144 "Completing this module registers credits into Sisu.",
145 cx.v5(b"page:1:1:block"),
146 )),
147 ),
148 ),
149 )
150 .module(
151 ModuleBuilder::new()
152 .order(1)
153 .name("Second module")
154 .ects(3.0)
155 .uh_course_code(CRS_102.to_string())
156 .credit_registration(credit_registration_config(CRS_102, false)),
157 )
158 .seed(&mut conn, app_config, &cx)
159 .await?;
160
161 seed_old_flow_course(&mut conn, app_config, org, teacher_user_id).await?;
162 seed_backfill_course(&mut conn, app_config, org, teacher_user_id).await?;
163 seed_import_outcomes_course(&mut conn, app_config, org, teacher_user_id).await?;
164 seed_grade_improvement_course(&mut conn, app_config, org, teacher_user_id).await?;
165 seed_admin_course(&mut conn, app_config, org, teacher_user_id).await?;
166 seed_states_course(&mut conn, app_config, org, teacher_user_id).await?;
167 seed_retry_course(&mut conn, app_config, org, teacher_user_id).await?;
168
169 info!("inserting credit registration students");
170
171 let linked_student = insert_student(
172 &mut conn,
173 cx.v5(b"user:linked-student"),
174 "credit-registration-linked-student@example.com",
175 "Zzyzx",
176 "Numberlinked",
177 )
178 .await?;
179 let unlinked_student = insert_student(
180 &mut conn,
181 cx.v5(b"user:unlinked-student"),
182 "credit-registration-unlinked-student@example.com",
183 "Zzyzx",
184 "Linkpending",
185 )
186 .await?;
187 let verified_email = insert_student(
188 &mut conn,
189 cx.v5(b"user:verified-email"),
190 "credit-registration-verified-email@example.com",
191 "Zzyzx",
192 "Fasttrack",
193 )
194 .await?;
195 let unverified_twin = insert_student(
196 &mut conn,
197 cx.v5(b"user:unverified-twin"),
198 "credit-registration-unverified-twin@example.com",
199 "Zzyzx",
200 "Nearmiss",
201 )
202 .await?;
203 let superseded_student = insert_student(
204 &mut conn,
205 cx.v5(b"user:superseded-attempts"),
206 "credit-registration-superseded@example.com",
207 "Zzyzx",
208 "Regraded",
209 )
210 .await?;
211
212 let link_claimer = insert_student(
213 &mut conn,
214 cx.v5(b"user:link-claimer"),
215 LINK_CLAIMER_EMAIL,
216 "Zzyzx",
217 "Claimer",
218 )
219 .await?;
220 let profile_empty = insert_student(
221 &mut conn,
222 cx.v5(b"user:profile-empty"),
223 PROFILE_EMPTY_EMAIL,
224 "Zzyzx",
225 "Emptyprofile",
226 )
227 .await?;
228
229 for student in [
230 &linked_student,
231 &unlinked_student,
232 &verified_email,
233 &unverified_twin,
234 &superseded_student,
235 &link_claimer,
236 &profile_empty,
237 ] {
238 course_instance_enrollments::insert(
239 &mut conn,
240 student.user_id,
241 suotar_course.id,
242 suotar_instance.id,
243 )
244 .await?;
245 }
246
247 for fixture in [
249 &IMPORT_TIMEOUT,
250 &SISU_OUTAGE,
251 &NO_ENROLMENT,
252 &TWO_ENROLMENTS,
253 &VERIFY_POLLING,
254 &VERIFY_MISREGISTERED,
255 &EMAILS_REGISTERED,
256 &EMAILS_NO_ENROLMENT,
257 &BANNER_STUCK,
258 &BANNER_REENROLS,
259 ] {
260 let student = seed_spec_student(
261 &mut conn,
262 &cx,
263 fixture,
264 suotar_course.id,
265 suotar_instance.id,
266 )
267 .await?;
268 seed_eligible_completion(&mut conn, &student, suotar_course.id, None).await?;
269 }
270
271 verified_student_numbers::insert(
272 &mut conn,
273 PKeyPolicy::Fixed(cx.v5(b"verified-student-number:linked-student")),
274 &NewVerifiedStudentNumber {
275 user_id: linked_student.user_id,
276 student_number: LINKED_STUDENT.student_number.to_string(),
277 sisu_person_id: LINKED_STUDENT.sisu_person_id(),
278 first_names: Some(LINKED_STUDENT.first_names.to_string()),
279 last_name: Some(LINKED_STUDENT.last_name.to_string()),
280 verified_via: StudentNumberVerificationMethod::EmailedLink,
281 verified_via_email: Some(LINKED_STUDENT.sisu_email.to_string()),
282 verified_via_email_match_field: None,
283 account_email_verified_at: None,
284 linked_by_user_id: None,
285 link_reason: None,
286 verified_from_course_id: Some(suotar_course.id),
287 },
288 )
289 .await?;
290 verified_student_numbers::insert(
291 &mut conn,
292 PKeyPolicy::Fixed(cx.v5(b"verified-student-number:superseded")),
293 &NewVerifiedStudentNumber {
294 user_id: superseded_student.user_id,
295 student_number: SUPERSEDED.student_number.to_string(),
296 sisu_person_id: SUPERSEDED.sisu_person_id(),
297 first_names: Some(SUPERSEDED.first_names.to_string()),
298 last_name: Some(SUPERSEDED.last_name.to_string()),
299 verified_via: StudentNumberVerificationMethod::EmailedLink,
300 verified_via_email: Some(SUPERSEDED.sisu_email.to_string()),
301 verified_via_email_match_field: None,
302 account_email_verified_at: None,
303 linked_by_user_id: None,
304 link_reason: None,
305 verified_from_course_id: Some(suotar_course.id),
306 },
307 )
308 .await?;
309
310 user_details::set_email_verified(
314 &mut conn,
315 verified_email.user_id,
316 EmailVerificationMethod::EmailedCode,
317 Utc::now() - Duration::days(30),
318 )
319 .await?;
320
321 seed_eligible_completion(&mut conn, &verified_email, suotar_course.id, None).await?;
322
323 info!("inserting credit registration fast track near misses");
324 seed_fast_track_near_misses(&mut conn, &cx).await?;
325
326 info!("inserting credit registration linking tokens");
327 seed_linking_tokens(&mut conn, &cx, suotar_course.id, unverified_twin.user_id).await?;
328
329 info!("inserting credit registration ledger history");
330 seed_superseded_attempt_pair(
331 &mut conn,
332 &superseded_student,
333 suotar_course.id,
334 suotar_instance.id,
335 )
336 .await?;
337
338 study_registry_registrars::insert(
339 &mut conn,
340 PKeyPolicy::Fixed(PULL_REGISTRAR_ID),
341 "Credit registration system tests (pull)",
342 PULL_REGISTRAR_SECRET_KEY,
343 )
344 .await?;
345
346 info!("inserting credit registration admin actions");
347 seed_admin_actions(&mut conn, &cx, suotar_course.id, teacher_user_id).await?;
348
349 push_mock_suotar_world(&base_url).await?;
350
351 Ok(SUOTAR_COURSE_ID)
352}
353
354async fn seed_fast_track_near_misses(conn: &mut PgConnection, cx: &SeedContext) -> Result<()> {
359 let now = Utc::now();
360 for (fixture, verified_at) in [
361 (&FAST_TRACK_STALE, now - Duration::days(400)),
362 (&FAST_TRACK_NAME_MISMATCH, now - Duration::days(30)),
363 (&FAST_TRACK_HAS_NUMBER, now - Duration::days(30)),
364 (&FAST_TRACK_SECONDARY_ONLY, now - Duration::days(30)),
365 (&FAST_TRACK_NO_MATCH, now - Duration::days(30)),
366 ] {
367 let account_email = fixture
368 .account_email
369 .ok_or_else(|| anyhow::anyhow!("a fast track near miss needs an account"))?;
370 let (first_name, last_name) =
373 if fixture.student_number == FAST_TRACK_NAME_MISMATCH.student_number {
374 ("Qqoqq", "Accountname")
375 } else {
376 (fixture.first_names, fixture.last_name)
377 };
378 let student = insert_student(
379 conn,
380 cx.v5(account_email.as_bytes()),
381 account_email,
382 first_name,
383 last_name,
384 )
385 .await?;
386 user_details::set_email_verified(
387 conn,
388 student.user_id,
389 EmailVerificationMethod::EmailedCode,
390 verified_at,
391 )
392 .await?;
393 if fixture.student_number == FAST_TRACK_HAS_NUMBER.student_number {
394 verified_student_numbers::insert(
395 conn,
396 PKeyPolicy::Fixed(cx.v5(b"verified-student-number:fast-track-has-number")),
397 &NewVerifiedStudentNumber {
398 user_id: student.user_id,
399 student_number: FAST_TRACK_OTHER_NUMBER.to_string(),
400 sisu_person_id: format!("hy-hlo-{FAST_TRACK_OTHER_NUMBER}"),
401 first_names: Some(fixture.first_names.to_string()),
402 last_name: Some(fixture.last_name.to_string()),
403 verified_via: StudentNumberVerificationMethod::EmailedLink,
404 verified_via_email: Some(account_email.to_string()),
405 verified_via_email_match_field: None,
406 account_email_verified_at: None,
407 linked_by_user_id: None,
408 link_reason: None,
409 verified_from_course_id: None,
410 },
411 )
412 .await?;
413 }
414 }
415 Ok(())
416}
417
418async fn push_mock_suotar_world(base_url: &str) -> Result<()> {
421 if !(bool_env_false_by_default("TEST_MODE")
422 && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT"))
423 {
424 info!("mock Suotar is not enabled; leaving its world alone");
425 return Ok(());
426 }
427 let url = SuotarConfiguration::mock_conf(base_url)?
428 .api_base_url
429 .join("control/command")?;
430 let mut payload = serde_json::to_value(mock_suotar_world())?;
431 if let Some(object) = payload.as_object_mut() {
432 object.insert("command".to_string(), serde_json::json!("pushWorld"));
433 }
434
435 info!("pushing the mock Suotar world");
436 let response = REQWEST_CLIENT
437 .post(url.clone())
438 .json(&payload)
439 .send()
440 .await?;
441 let status = response.status();
442 if !status.is_success() {
443 let body = response.text().await.unwrap_or_default();
444 anyhow::bail!("pushing the mock Suotar world to {url} failed with {status}: {body}");
446 }
447 Ok(())
448}
449
450fn credit_registration_config(course_code: &str, with_product: bool) -> CreditRegistrationSeed {
456 CreditRegistrationSeed {
457 open_university_product_id: with_product.then(|| product_id(course_code)),
458 grade_scale_id: None,
459 active_realisation_ids: vec![mock_ids::realisation_id(
460 course_code,
461 RealisationKind::Degree,
462 )],
463 paused_reason: None,
464 }
465}
466
467fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
468 CourseInstanceConfig {
469 name: None,
470 description: None,
471 support_email: None,
472 teacher_in_charge_name: "admin".to_string(),
473 teacher_in_charge_email: "admin@example.com".to_string(),
474 opening_time: None,
475 closing_time: None,
476 instance_id: Some(instance_id),
477 }
478}
479
480async fn seed_old_flow_course(
487 conn: &mut PgConnection,
488 app_config: &ApplicationConfiguration,
489 org: Uuid,
490 teacher_user_id: Uuid,
491) -> Result<()> {
492 let cx = SeedContext {
493 teacher: teacher_user_id,
494 org,
495 base_course_ns: OLD_FLOW_COURSE_ID,
496 };
497 let registrar_id = get_or_create_default_registrar(conn).await?;
498
499 let still_legacy = insert_student(
500 conn,
501 cx.v5(b"user:still-legacy"),
502 "credit-registration-old-flow-still-legacy@example.com",
503 "Zzyzx",
504 "Stilllegacy",
505 )
506 .await?;
507 let already_cut_over = insert_student(
508 conn,
509 cx.v5(b"user:already-cut-over"),
510 "credit-registration-old-flow-already-cut-over@example.com",
511 "Zzyzx",
512 "Alreadycutover",
513 )
514 .await?;
515
516 let (course, instance, _) =
517 CourseBuilder::new("Credit registration old flow", OLD_FLOW_COURSE_SLUG)
518 .desc("Fixture course left on the legacy open university registration flow.")
519 .course_id(OLD_FLOW_COURSE_ID)
520 .instance(instance_config(cx.v5(b"instance:old-flow")))
521 .module(
522 ModuleBuilder::new()
523 .order(0)
524 .ects(5.0)
525 .uh_course_code(CRS_OLD_101.to_string())
526 .register_to_open_university(true)
527 .completion(
528 CompletionBuilder::new(still_legacy.user_id)
529 .email(still_legacy.email.clone())
530 .grade(3)
531 .passed(true)
532 .prerequisite_modules_completed(true),
533 ),
534 )
535 .module(
536 ModuleBuilder::new()
537 .order(1)
538 .name("Cut over to Suotar")
539 .ects(5.0)
540 .uh_course_code(CRS_OLD_102.to_string())
541 .credit_registration(credit_registration_config(CRS_OLD_102, false))
542 .default_registrar(registrar_id)
543 .completion(
544 CompletionBuilder::new(already_cut_over.user_id)
545 .email(already_cut_over.email.clone())
546 .grade(3)
547 .passed(true)
548 .prerequisite_modules_completed(true)
549 .registered(
550 CompletionRegisteredBuilder::new().real_student_number("900001002"),
551 ),
552 ),
553 )
554 .seed(conn, app_config, &cx)
555 .await?;
556
557 for student in [&still_legacy, &already_cut_over] {
558 course_instance_enrollments::insert(conn, student.user_id, course.id, instance.id).await?;
559 }
560 Ok(())
561}
562
563async fn seed_backfill_course(
566 conn: &mut PgConnection,
567 app_config: &ApplicationConfiguration,
568 org: Uuid,
569 teacher_user_id: Uuid,
570) -> Result<()> {
571 let cx = SeedContext {
572 teacher: teacher_user_id,
573 org,
574 base_course_ns: BACKFILL_COURSE_ID,
575 };
576 let registrar_id = get_or_create_default_registrar(conn).await?;
577
578 let mut module = ModuleBuilder::new()
579 .order(0)
580 .ects(5.0)
581 .uh_course_code(CRS_BACKFILL_101.to_string())
582 .default_registrar(registrar_id)
583 .chapter(
586 ChapterBuilder::new(1, "Content")
587 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page")),
588 );
589
590 for index in 1..=4 {
591 let student = insert_student(
592 conn,
593 cx.v5(format!("user:backfill:{index}").as_bytes()),
594 &format!("credit-registration-backfill-{index}@example.com"),
595 "Zzyzx",
596 &format!("Backfill{index}"),
597 )
598 .await?;
599 let mut completion = CompletionBuilder::new(student.user_id)
600 .email(student.email.clone())
601 .grade(3)
602 .passed(true)
603 .prerequisite_modules_completed(true);
604 if index == 1 {
605 completion = completion.registered(
606 CompletionRegisteredBuilder::new()
607 .real_student_number(BACKFILL_STUDENTS[index - 1].student_number.to_string()),
608 );
609 }
610 module = module.completion(completion);
611 }
612 let failed_student = insert_student(
613 conn,
614 cx.v5(b"user:backfill:failed"),
615 "credit-registration-backfill-failed@example.com",
616 "Zzyzx",
617 "Backfillfailed",
618 )
619 .await?;
620 module = module.completion(
621 CompletionBuilder::new(failed_student.user_id)
622 .email(failed_student.email.clone())
623 .grade(0)
624 .passed(false)
625 .prerequisite_modules_completed(true),
626 );
627
628 let (course, instance, _) = CourseBuilder::new(
629 "Credit registration backfill",
630 BACKFILL_COURSE_SLUG,
631 )
632 .desc("Fixture course with pre-existing passed completions, for the backfill-on-opt-in spec.")
633 .course_id(BACKFILL_COURSE_ID)
634 .instance(instance_config(cx.v5(b"instance:backfill")))
635 .module(module)
636 .seed(conn, app_config, &cx)
637 .await?;
638
639 for index in 1..=4 {
640 let user_id = cx.v5(format!("user:backfill:{index}").as_bytes());
641 course_instance_enrollments::insert(conn, user_id, course.id, instance.id).await?;
642 }
643 course_instance_enrollments::insert(conn, failed_student.user_id, course.id, instance.id)
644 .await?;
645 Ok(())
646}
647
648async fn seed_admin_course(
653 conn: &mut PgConnection,
654 app_config: &ApplicationConfiguration,
655 org: Uuid,
656 teacher_user_id: Uuid,
657) -> Result<()> {
658 let cx = SeedContext {
659 teacher: teacher_user_id,
660 org,
661 base_course_ns: ADMIN_COURSE_ID,
662 };
663 let (course, instance, _) = CourseBuilder::new("Credit registration admin", ADMIN_COURSE_SLUG)
664 .desc("Fixture course for the admin dashboard's account linking views.")
665 .course_id(ADMIN_COURSE_ID)
666 .role(teacher_user_id, UserRole::Teacher)
667 .instance(instance_config(cx.v5(b"instance:admin")))
668 .module(
669 ModuleBuilder::new()
670 .order(0)
671 .ects(5.0)
672 .uh_course_code(CRS_ADMIN_101.to_string())
673 .credit_registration(credit_registration_config(CRS_ADMIN_101, true)),
674 )
675 .seed(conn, app_config, &cx)
676 .await?;
677
678 let unlinked = seed_spec_account(conn, &cx, &ADMIN_UNLINKED, course.id, instance.id).await?;
679 seed_eligible_completion(conn, &unlinked, course.id, None).await?;
680 let linked = seed_spec_student(conn, &cx, &ADMIN_LINKED, course.id, instance.id).await?;
681 seed_eligible_completion(conn, &linked, course.id, None).await?;
682
683 for fixture in [&ADMIN_STALE, &TEACHER_RESEND_CAPPED] {
684 for suffix in MAILED_ADDRESS_SUFFIXES {
685 let address = format!("{suffix}{}", fixture.sisu_email);
686 let claimed = credit_registration_account_linking_emails::claim_send_slot(
687 conn,
688 &NewAccountLinkingEmail {
689 student_number: fixture.student_number.to_string(),
690 sisu_person_id: fixture.sisu_person_id(),
691 course_id: course.id,
692 emailed_to: address.clone(),
693 student_number_verification_token_id: None,
694 email_delivery_id: None,
695 },
696 )
697 .await?;
698 anyhow::ensure!(
699 claimed.is_some(),
700 "the dedup key refused a seeded linking mail to {address}"
701 );
702 }
703 }
704 Ok(())
705}
706
707async fn seed_states_course(
712 conn: &mut PgConnection,
713 app_config: &ApplicationConfiguration,
714 org: Uuid,
715 teacher_user_id: Uuid,
716) -> Result<()> {
717 let cx = SeedContext {
718 teacher: teacher_user_id,
719 org,
720 base_course_ns: STATES_COURSE_ID,
721 };
722 let (course, instance, _) = CourseBuilder::new(
723 "Credit registration states",
724 STATES_COURSE_SLUG,
725 )
726 .desc("Fixture course holding one frozen registration per state and per error code.")
727 .course_id(STATES_COURSE_ID)
728 .role(teacher_user_id, UserRole::Teacher)
729 .instance(instance_config(cx.v5(b"instance:states")))
730 .module(
731 ModuleBuilder::new()
732 .order(0)
733 .ects(5.0)
734 .uh_course_code(CRS_STATES_101.to_string())
735 .credit_registration(CreditRegistrationSeed {
736 paused_reason: Some(
737 "Seeded fixture: these rows are read by the teacher and admin views and must not move."
738 .to_string(),
739 ),
740 ..credit_registration_config(CRS_STATES_101, false)
741 }),
742 )
743 .seed(conn, app_config, &cx)
744 .await?;
745
746 for (index, state) in CreditRegistrationState::ALL.iter().enumerate() {
747 seed_frozen_registration(
748 conn,
749 &cx,
750 course.id,
751 instance.id,
752 index + 1,
753 &format!("State{:02}", index + 1),
754 *state,
755 None,
756 )
757 .await?;
758 }
759 for (index, error_code) in CreditRegistrationErrorCode::ALL.iter().enumerate() {
761 seed_frozen_registration(
762 conn,
763 &cx,
764 course.id,
765 instance.id,
766 50 + index,
767 &format!("Error{:02}", index + 1),
768 CreditRegistrationState::FailedPermanent,
769 Some(*error_code),
770 )
771 .await?;
772 }
773 Ok(())
774}
775
776async fn seed_retry_course(
783 conn: &mut PgConnection,
784 app_config: &ApplicationConfiguration,
785 org: Uuid,
786 teacher_user_id: Uuid,
787) -> Result<()> {
788 let cx = SeedContext {
789 teacher: teacher_user_id,
790 org,
791 base_course_ns: RETRY_COURSE_ID,
792 };
793 let (course, instance, _) = CourseBuilder::new("Credit registration retry", RETRY_COURSE_SLUG)
794 .desc("Fixture course holding the registrations a teacher retries, and the ones they cannot.")
795 .course_id(RETRY_COURSE_ID)
796 .role(teacher_user_id, UserRole::Teacher)
797 .instance(instance_config(cx.v5(b"instance:retry")))
798 .module(
799 ModuleBuilder::new()
800 .order(0)
801 .ects(5.0)
802 .uh_course_code(CRS_RETRY_101.to_string())
803 .credit_registration(CreditRegistrationSeed {
804 paused_reason: Some(
805 "Seeded fixture: the retry specs read these rows and the workers must not move them."
806 .to_string(),
807 ),
808 ..credit_registration_config(CRS_RETRY_101, false)
809 }),
810 )
811 .seed(conn, app_config, &cx)
812 .await?;
813
814 for (person, last_name, state) in [
817 (80, "Retry01", CreditRegistrationState::FailedPermanent),
818 (81, "Retry02", CreditRegistrationState::FailedPermanent),
819 (82, "Retry03", CreditRegistrationState::SubmissionUncertain),
820 (83, "Retry04", CreditRegistrationState::Cancelled),
821 ] {
822 seed_frozen_registration(
823 conn,
824 &cx,
825 course.id,
826 instance.id,
827 person,
828 last_name,
829 state,
830 None,
831 )
832 .await?;
833 }
834 Ok(())
835}
836
837#[allow(clippy::too_many_arguments)]
843async fn seed_frozen_registration(
844 conn: &mut PgConnection,
845 cx: &SeedContext,
846 course_id: Uuid,
847 course_instance_id: Uuid,
848 person: usize,
849 last_name: &str,
850 state: CreditRegistrationState,
851 error_code: Option<CreditRegistrationErrorCode>,
852) -> Result<()> {
853 let student_number = format!("9000008{person:02}");
854 let account_email = format!(
855 "credit-registration-{}@example.com",
856 last_name.to_lowercase()
857 );
858 let student = insert_student(
859 conn,
860 cx.v5(account_email.as_bytes()),
861 &account_email,
862 "Zzyzx",
863 last_name,
864 )
865 .await?;
866 course_instance_enrollments::insert(conn, student.user_id, course_id, course_instance_id)
867 .await?;
868 let verified_via = match person {
869 1 => Some(StudentNumberVerificationMethod::EmailedLink),
870 2 => Some(StudentNumberVerificationMethod::AdminManual),
871 _ => None,
872 };
873 if let Some(verified_via) = verified_via {
874 verified_student_numbers::insert(
875 conn,
876 PKeyPolicy::Fixed(cx.v5(format!("verified:{student_number}").as_bytes())),
877 &NewVerifiedStudentNumber {
878 user_id: student.user_id,
879 student_number: student_number.clone(),
880 sisu_person_id: format!("hy-hlo-{student_number}"),
881 first_names: Some("Zzyzx".to_string()),
882 last_name: Some(last_name.to_string()),
883 verified_via,
884 verified_via_email: (verified_via != StudentNumberVerificationMethod::AdminManual)
885 .then(|| format!("zzyzx.{}@helsinki.example", last_name.to_lowercase())),
886 verified_via_email_match_field: None,
887 account_email_verified_at: None,
888 linked_by_user_id: (verified_via == StudentNumberVerificationMethod::AdminManual)
889 .then_some(cx.teacher),
890 link_reason: (verified_via == StudentNumberVerificationMethod::AdminManual).then(
891 || "Seeded fixture: the address Sisu holds rejects our mail.".to_string(),
892 ),
893 verified_from_course_id: Some(course_id),
894 },
895 )
896 .await?;
897 }
898
899 let completion_id = seed_eligible_completion(conn, &student, course_id, None).await?;
900 let course_module_id =
901 headless_lms_models::course_modules::get_default_by_course_id(conn, course_id)
902 .await?
903 .id;
904 let id = credit_registrations::insert(
905 conn,
906 PKeyPolicy::Fixed(cx.v5(format!("credit-registration:{account_email}").as_bytes())),
907 &NewCreditRegistration {
908 course_module_completion_id: completion_id,
909 user_id: student.user_id,
910 course_id,
911 course_module_id,
912 course_instance_id,
913 attempt_number: 1,
914 },
915 Some("Seeded fixture"),
916 )
917 .await?;
918 credit_registrations::transition(
919 conn,
920 id,
921 &Transition {
922 error_code,
923 needs_admin_attention: error_code.map(|_| true),
924 ..Transition::planted(state)
925 },
926 )
927 .await?;
928 Ok(())
929}
930
931async fn seed_import_outcomes_course(
934 conn: &mut PgConnection,
935 app_config: &ApplicationConfiguration,
936 org: Uuid,
937 teacher_user_id: Uuid,
938) -> Result<()> {
939 let cx = SeedContext {
940 teacher: teacher_user_id,
941 org,
942 base_course_ns: IMPORT_OUTCOMES_COURSE_ID,
943 };
944 let mut course = CourseBuilder::new(
945 "Credit registration import outcomes",
946 IMPORT_OUTCOMES_COURSE_SLUG,
947 )
948 .desc("Fixture course whose modules each provoke one Sisu import error.")
949 .course_id(IMPORT_OUTCOMES_COURSE_ID)
950 .instance(instance_config(cx.v5(b"instance:import-outcomes")));
951 for (order, course_code) in IMPORT_OUTCOME_COURSE_CODES.iter().enumerate() {
952 let mut module = ModuleBuilder::new()
953 .order(order as i32)
954 .ects(5.0)
955 .uh_course_code(course_code.to_string())
956 .credit_registration(credit_registration_config(course_code, false));
957 if order > 0 {
958 module = module.name(format!("Module {course_code}"));
959 }
960 course = course.module(module);
961 }
962 let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
963 let student = seed_spec_student(conn, &cx, &IMPORT_OUTCOMES, course.id, instance.id).await?;
964 for module in headless_lms_models::course_modules::get_by_course_id(conn, course.id).await? {
965 course_module_completions::insert_seed_row(
966 conn,
967 &NewCourseModuleCompletionSeed {
968 course_id: course.id,
969 course_module_id: module.id,
970 user_id: student.user_id,
971 completion_date: Some(Utc::now() - Duration::days(1)),
972 completion_language: Some("en-US".to_string()),
973 eligible_for_ects: Some(true),
974 email: Some(student.email.clone()),
975 grade: None,
976 passed: Some(true),
977 prerequisite_modules_completed: Some(true),
978 needs_to_be_reviewed: Some(false),
979 },
980 )
981 .await?;
982 }
983 Ok(())
984}
985
986async fn seed_grade_improvement_course(
987 conn: &mut PgConnection,
988 app_config: &ApplicationConfiguration,
989 org: Uuid,
990 teacher_user_id: Uuid,
991) -> Result<()> {
992 let cx = SeedContext {
993 teacher: teacher_user_id,
994 org,
995 base_course_ns: GRADE_IMPROVEMENT_COURSE_ID,
996 };
997 let (course, instance, _) = CourseBuilder::new(
998 "Credit registration grade improvement",
999 GRADE_IMPROVEMENT_COURSE_SLUG,
1000 )
1001 .desc("Fixture course whose module is graded rather than pass/fail.")
1002 .course_id(GRADE_IMPROVEMENT_COURSE_ID)
1003 .instance(instance_config(cx.v5(b"instance:grade-improvement")))
1004 .module(
1005 ModuleBuilder::new()
1006 .order(0)
1007 .ects(5.0)
1008 .uh_course_code(CRS_GRADED_101.to_string())
1009 .credit_registration(credit_registration_config(CRS_GRADED_101, false)),
1010 )
1011 .seed(conn, app_config, &cx)
1012 .await?;
1013 let student = seed_spec_student(conn, &cx, &GRADE_IMPROVEMENT, course.id, instance.id).await?;
1014 seed_eligible_completion(conn, &student, course.id, Some(3)).await?;
1015 Ok(())
1016}
1017
1018async fn seed_spec_student(
1020 conn: &mut PgConnection,
1021 cx: &SeedContext,
1022 fixture: &MockPersonFixture,
1023 course_id: Uuid,
1024 course_instance_id: Uuid,
1025) -> Result<SeededStudent> {
1026 let student = seed_spec_account(conn, cx, fixture, course_id, course_instance_id).await?;
1027 link_student_number(
1028 conn,
1029 cx,
1030 fixture,
1031 student.user_id,
1032 course_id,
1033 StudentNumberVerificationMethod::EmailedLink,
1034 )
1035 .await?;
1036 Ok(student)
1037}
1038
1039async fn seed_spec_account(
1041 conn: &mut PgConnection,
1042 cx: &SeedContext,
1043 fixture: &MockPersonFixture,
1044 course_id: Uuid,
1045 course_instance_id: Uuid,
1046) -> Result<SeededStudent> {
1047 let account_email = fixture
1048 .account_email
1049 .ok_or_else(|| anyhow::anyhow!("a driven spec actor needs an account"))?;
1050 let student = insert_student(
1051 conn,
1052 cx.v5(account_email.as_bytes()),
1053 account_email,
1054 fixture.first_names,
1055 fixture.last_name,
1056 )
1057 .await?;
1058 course_instance_enrollments::insert(conn, student.user_id, course_id, course_instance_id)
1059 .await?;
1060 Ok(student)
1061}
1062
1063async fn link_student_number(
1064 conn: &mut PgConnection,
1065 cx: &SeedContext,
1066 fixture: &MockPersonFixture,
1067 user_id: Uuid,
1068 course_id: Uuid,
1069 verified_via: StudentNumberVerificationMethod,
1070) -> Result<()> {
1071 verified_student_numbers::insert(
1072 conn,
1073 PKeyPolicy::Fixed(cx.v5(format!("verified:{}", fixture.student_number).as_bytes())),
1074 &NewVerifiedStudentNumber {
1075 user_id,
1076 student_number: fixture.student_number.to_string(),
1077 sisu_person_id: fixture.sisu_person_id(),
1078 first_names: Some(fixture.first_names.to_string()),
1079 last_name: Some(fixture.last_name.to_string()),
1080 verified_via,
1081 verified_via_email: (verified_via != StudentNumberVerificationMethod::AdminManual)
1082 .then(|| fixture.sisu_email.to_string()),
1083 verified_via_email_match_field: None,
1084 account_email_verified_at: None,
1085 linked_by_user_id: (verified_via == StudentNumberVerificationMethod::AdminManual)
1086 .then_some(cx.teacher),
1087 link_reason: (verified_via == StudentNumberVerificationMethod::AdminManual)
1088 .then(|| "Seeded fixture: the address Sisu holds rejects our mail.".to_string()),
1089 verified_from_course_id: Some(course_id),
1090 },
1091 )
1092 .await?;
1093 Ok(())
1094}
1095
1096async fn seed_eligible_completion(
1099 conn: &mut PgConnection,
1100 student: &SeededStudent,
1101 course_id: Uuid,
1102 grade: Option<i32>,
1103) -> Result<Uuid> {
1104 let course_module_id =
1105 headless_lms_models::course_modules::get_default_by_course_id(conn, course_id)
1106 .await?
1107 .id;
1108 let completion_id = course_module_completions::insert_seed_row(
1109 conn,
1110 &NewCourseModuleCompletionSeed {
1111 course_id,
1112 course_module_id,
1113 user_id: student.user_id,
1114 completion_date: Some(Utc::now() - Duration::days(1)),
1115 completion_language: Some("en-US".to_string()),
1116 eligible_for_ects: Some(true),
1117 email: Some(student.email.clone()),
1118 grade,
1119 passed: Some(true),
1120 prerequisite_modules_completed: Some(true),
1121 needs_to_be_reviewed: Some(false),
1122 },
1123 )
1124 .await?;
1125 Ok(completion_id)
1126}
1127
1128async fn insert_student(
1129 conn: &mut PgConnection,
1130 user_id: Uuid,
1131 email: &str,
1132 first_name: &str,
1133 last_name: &str,
1134) -> Result<SeededStudent> {
1135 let user_id = users::insert(
1136 conn,
1137 PKeyPolicy::Fixed(user_id),
1138 email,
1139 Some(first_name),
1140 Some(last_name),
1141 )
1142 .await?;
1143 user_details::update_user_country(conn, user_id, "fi").await?;
1144 let password = email
1147 .split('@')
1148 .next()
1149 .expect("split always yields one element");
1150 let hash = hash_password(&SecretString::new(password.to_string().into()))
1151 .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
1152 upsert_user_password(conn, user_id, &hash).await?;
1153 Ok(SeededStudent {
1154 user_id,
1155 email: email.to_string(),
1156 })
1157}
1158
1159async fn seed_linking_tokens(
1162 conn: &mut PgConnection,
1163 cx: &SeedContext,
1164 course_id: Uuid,
1165 claimed_by_user_id: Uuid,
1166) -> Result<()> {
1167 let now = Utc::now();
1168 student_number_verification_tokens::insert_seed_row(
1169 conn,
1170 PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
1171 &SeedStudentNumberVerificationToken {
1172 token: LINKING_TOKEN_VALID.to_string(),
1173 student_number: LINK_VALID.student_number.to_string(),
1174 sisu_person_id: LINK_VALID.sisu_person_id(),
1175 first_names: Some(LINK_VALID.first_names.to_string()),
1176 last_name: Some(LINK_VALID.last_name.to_string()),
1177 emailed_to: LINK_VALID.sisu_email.to_string(),
1178 course_id: Some(course_id),
1179 expires_at: now + Duration::days(14),
1180 used_at: None,
1181 claimed_by_user_id: None,
1182 },
1183 )
1184 .await?;
1185 student_number_verification_tokens::insert_seed_row(
1186 conn,
1187 PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
1188 &SeedStudentNumberVerificationToken {
1189 token: LINKING_TOKEN_EXPIRED.to_string(),
1190 student_number: LINK_EXPIRED.student_number.to_string(),
1191 sisu_person_id: LINK_EXPIRED.sisu_person_id(),
1192 first_names: Some(LINK_EXPIRED.first_names.to_string()),
1193 last_name: Some(LINK_EXPIRED.last_name.to_string()),
1194 emailed_to: LINK_EXPIRED.sisu_email.to_string(),
1195 course_id: Some(course_id),
1196 expires_at: now - Duration::days(1),
1197 used_at: None,
1198 claimed_by_user_id: None,
1199 },
1200 )
1201 .await?;
1202 student_number_verification_tokens::insert_seed_row(
1203 conn,
1204 PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
1205 &SeedStudentNumberVerificationToken {
1206 token: LINKING_TOKEN_ALREADY_USED.to_string(),
1207 student_number: LINK_USED.student_number.to_string(),
1208 sisu_person_id: LINK_USED.sisu_person_id(),
1209 first_names: Some(LINK_USED.first_names.to_string()),
1210 last_name: Some(LINK_USED.last_name.to_string()),
1211 emailed_to: LINK_USED.sisu_email.to_string(),
1212 course_id: Some(course_id),
1213 expires_at: now + Duration::days(14),
1214 used_at: Some(now - Duration::hours(1)),
1215 claimed_by_user_id: Some(claimed_by_user_id),
1216 },
1217 )
1218 .await?;
1219 student_number_verification_tokens::insert_seed_row(
1220 conn,
1221 PKeyPolicy::Fixed(cx.v5(b"linking-token:conflict")),
1222 &SeedStudentNumberVerificationToken {
1223 token: LINKING_TOKEN_CONFLICT.to_string(),
1224 student_number: LINKED_STUDENT.student_number.to_string(),
1225 sisu_person_id: LINKED_STUDENT.sisu_person_id(),
1226 first_names: Some(LINKED_STUDENT.first_names.to_string()),
1227 last_name: Some(LINKED_STUDENT.last_name.to_string()),
1228 emailed_to: LINKED_STUDENT.sisu_email.to_string(),
1229 course_id: Some(course_id),
1230 expires_at: now + Duration::days(14),
1231 used_at: None,
1232 claimed_by_user_id: None,
1233 },
1234 )
1235 .await?;
1236 Ok(())
1237}
1238
1239async fn seed_superseded_attempt_pair(
1242 conn: &mut PgConnection,
1243 student: &SeededStudent,
1244 course_id: Uuid,
1245 course_instance_id: Uuid,
1246) -> Result<()> {
1247 let course_module_id =
1248 headless_lms_models::course_modules::get_default_by_course_id(conn, course_id)
1249 .await?
1250 .id;
1251 let completion_id = course_module_completions::insert_seed_row(
1252 conn,
1253 &NewCourseModuleCompletionSeed {
1254 course_id,
1255 course_module_id,
1256 user_id: student.user_id,
1257 completion_date: Some(Utc::now() - Duration::days(20)),
1258 completion_language: Some("en-US".to_string()),
1259 eligible_for_ects: Some(true),
1260 email: Some(student.email.clone()),
1261 grade: Some(4),
1262 passed: Some(true),
1263 prerequisite_modules_completed: Some(true),
1264 needs_to_be_reviewed: Some(false),
1265 },
1266 )
1267 .await?;
1268
1269 let mut tx = conn.begin().await?;
1272 let attempt_1 = insert_registered_attempt(
1273 &mut tx,
1274 SUPERSEDED_ATTEMPT_1_ID,
1275 completion_id,
1276 student.user_id,
1277 course_id,
1278 course_module_id,
1279 course_instance_id,
1280 1,
1281 "3",
1282 )
1283 .await?;
1284 credit_registrations::mark_superseded(&mut tx, attempt_1, SUPERSEDED_ATTEMPT_2_ID).await?;
1285 insert_registered_attempt(
1286 &mut tx,
1287 SUPERSEDED_ATTEMPT_2_ID,
1288 completion_id,
1289 student.user_id,
1290 course_id,
1291 course_module_id,
1292 course_instance_id,
1293 2,
1294 "4",
1295 )
1296 .await?;
1297 tx.commit().await?;
1298 Ok(())
1299}
1300
1301#[allow(clippy::too_many_arguments)]
1302async fn insert_registered_attempt(
1303 conn: &mut PgConnection,
1304 id: Uuid,
1305 course_module_completion_id: Uuid,
1306 user_id: Uuid,
1307 course_id: Uuid,
1308 course_module_id: Uuid,
1309 course_instance_id: Uuid,
1310 attempt_number: i32,
1311 grade_id: &str,
1312) -> Result<Uuid> {
1313 let id = credit_registrations::insert(
1314 conn,
1315 PKeyPolicy::Fixed(id),
1316 &NewCreditRegistration {
1317 course_module_completion_id,
1318 user_id,
1319 course_id,
1320 course_module_id,
1321 course_instance_id,
1322 attempt_number,
1323 },
1324 Some("Seeded fixture"),
1325 )
1326 .await?;
1327 credit_registrations::set_payload_snapshot(
1328 conn,
1329 id,
1330 &PayloadSnapshot {
1331 student_number: SUPERSEDED.student_number.to_string(),
1332 sisu_person_id: SUPERSEDED.sisu_person_id(),
1333 uh_course_code: CRS_101.to_string(),
1334 selected_enrolment_id: Some(format!("otm-{}-degree", SUPERSEDED.student_number)),
1335 selected_enrolment_kind: Some("degree".to_string()),
1336 selected_enrolment_realisation_id: Some("hy-opt-cur-900000901".to_string()),
1337 attainment_date: (Utc::now() - Duration::days(20)).date_naive(),
1338 attainment_language: "en".to_string(),
1339 grade_scale_id: "sis-0-5".to_string(),
1340 grade_id: grade_id.to_string(),
1341 credits: 5.0,
1342 },
1343 )
1344 .await?;
1345 credit_registrations::transition(
1346 conn,
1347 id,
1348 &Transition::planted(CreditRegistrationState::Registered),
1349 )
1350 .await?;
1351 Ok(id)
1352}
1353
1354async fn seed_admin_actions(
1357 conn: &mut PgConnection,
1358 cx: &SeedContext,
1359 course_id: Uuid,
1360 teacher_user_id: Uuid,
1361) -> Result<()> {
1362 let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
1363 .await?
1364 .id;
1365 credit_registration_admin_actions::record(
1366 conn,
1367 &NewCreditRegistrationAdminAction {
1368 target_id: Some(SUPERSEDED_ATTEMPT_1_ID),
1369 reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
1370 before_state: Some(CreditRegistrationState::SubmissionUncertain),
1371 after_state: Some(CreditRegistrationState::Registered),
1372 affected_row_count: Some(1),
1373 ..NewCreditRegistrationAdminAction::new(
1374 CreditRegistrationAdminAction::TransitionItem,
1375 CreditRegistrationAdminActionTarget::CreditRegistration,
1376 admin_user_id,
1377 GLOBAL_ADMIN_ROLE,
1378 )
1379 },
1380 )
1381 .await?;
1382 credit_registration_admin_actions::record(
1383 conn,
1384 &NewCreditRegistrationAdminAction {
1385 target_id: Some(cx.v5(b"linking-token:valid")),
1386 actor_course_id: Some(course_id),
1387 reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
1388 affected_row_count: Some(1),
1389 ..NewCreditRegistrationAdminAction::new(
1390 CreditRegistrationAdminAction::ResendLinkEmail,
1391 CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
1392 teacher_user_id,
1393 COURSE_TEACHER_ROLE,
1394 )
1395 },
1396 )
1397 .await?;
1398 Ok(())
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 use super::*;
1404
1405 #[test]
1408 fn seeded_linking_tokens_are_long_enough() {
1409 for token in [
1410 LINKING_TOKEN_VALID,
1411 LINKING_TOKEN_EXPIRED,
1412 LINKING_TOKEN_ALREADY_USED,
1413 LINKING_TOKEN_CONFLICT,
1414 ] {
1415 assert!(token.len() >= 128, "token too short: {}", token.len());
1416 }
1417 }
1418}