1use std::collections::HashMap;
8
9use anyhow::Result;
10use chrono::{Duration, Utc};
11use headless_lms_base::config::{
12 ApplicationConfiguration, SuotarConfiguration, bool_env_false_by_default,
13};
14use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
15use headless_lms_models::{
16 PKeyPolicy,
17 course_instance_enrollments::{self, NewCourseInstanceEnrollment},
18 course_module_completions::{self, NewCourseModuleCompletionSeed},
19 course_modules,
20 credit_registration_account_linking_emails::{self, NewAccountLinkingEmail},
21 credit_registration_admin_actions::{
22 self, COURSE_TEACHER_ROLE, CreditRegistrationAdminAction,
23 CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE, NewCreditRegistrationAdminAction,
24 },
25 credit_registration_enrolment_check_signals,
26 credit_registrations::{
27 self, CreditRegistrationErrorCode, CreditRegistrationState, NewCreditRegistration,
28 PayloadSnapshot, Transition,
29 },
30 open_university_registration_links,
31 roles::UserRole,
32 secret::DbSecret,
33 student_number_verification_tokens::{self, SeedStudentNumberVerificationToken},
34 study_registry_registrars::{self, get_or_create_default_registrar},
35 user_ai_usage_notice_acknowledgements, user_details,
36 user_passwords::{hash_password, upsert_user_password},
37 users,
38 verified_student_numbers::{self, NewVerifiedStudentNumber, StudentNumberVerificationMethod},
39};
40use headless_lms_utils::http::REQWEST_CLIENT;
41use secrecy::SecretString;
42use sqlx::{Connection, PgConnection};
43use tracing::info;
44use uuid::Uuid;
45
46use crate::mock_suotar::fixtures::*;
47use crate::programs::seed::builder::{
48 chapter::ChapterBuilder,
49 context::SeedContext,
50 course::{CourseBuilder, CourseInstanceConfig},
51 module::{
52 CompletionBuilder, CompletionRegisteredBuilder, CreditRegistrationSeed, ModuleBuilder,
53 },
54 page::PageBuilder,
55};
56use crate::programs::seed::seed_courses::CommonCourseData;
57use crate::programs::seed::seed_helpers::paragraph;
58
59pub const CERTIFICATE_DETOUR_COURSE_ID: Uuid =
62 Uuid::from_u128(0xc5ed17ea_0010_4a5e_9e6e_c0de00000010);
63pub const CERTIFICATE_DETOUR_COURSE_SLUG: &str = "credit-registration-certificate-detour";
64pub const CRS_DETOUR_101: &str = "CRS-DETOUR-101";
65
66pub const PULL_REGISTRAR_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0008_4a5e_9e6e_c0de00000008);
69pub const PULL_REGISTRAR_SECRET_KEY: &str = "credit-registration-system-tests-pull-registrar";
70
71pub const SUPERSEDED_ATTEMPT_1_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0901_4a5e_9e6e_c0de00000901);
73pub const SUPERSEDED_ATTEMPT_2_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0902_4a5e_9e6e_c0de00000902);
74
75pub const LINKING_TOKEN_VALID: &str = concat!(
80 "11111111-1111-1111-1111-111111111111",
81 "11111111-1111-1111-1111-111111111111",
82 "11111111-1111-1111-1111-111111111111",
83 "11111111-1111-1111-1111-111111111111",
84);
85pub const LINKING_TOKEN_EXPIRED: &str = concat!(
86 "22222222-2222-2222-2222-222222222222",
87 "22222222-2222-2222-2222-222222222222",
88 "22222222-2222-2222-2222-222222222222",
89 "22222222-2222-2222-2222-222222222222",
90);
91pub const LINKING_TOKEN_ALREADY_USED: &str = concat!(
92 "33333333-3333-3333-3333-333333333333",
93 "33333333-3333-3333-3333-333333333333",
94 "33333333-3333-3333-3333-333333333333",
95 "33333333-3333-3333-3333-333333333333",
96);
97pub const LINKING_TOKEN_CONFLICT: &str = concat!(
100 "44444444-4444-4444-4444-444444444444",
101 "44444444-4444-4444-4444-444444444444",
102 "44444444-4444-4444-4444-444444444444",
103 "44444444-4444-4444-4444-444444444444",
104);
105
106const PROFILE_EMPTY_EMAIL: &str = "student5@example.com";
108
109struct SeededStudent {
111 user_id: Uuid,
112 email: String,
113}
114
115struct LinkedStudents {
117 by_student_number: HashMap<&'static str, SeededStudent>,
118}
119
120impl LinkedStudents {
121 fn get(&self, fixture: &MockPersonFixture) -> Result<&SeededStudent> {
122 self.by_student_number
123 .get(fixture.student_number)
124 .ok_or_else(|| anyhow::anyhow!("{} was never seeded", fixture.student_number))
125 }
126}
127
128#[derive(Clone, Copy)]
130struct SeededCourse {
131 course_id: Uuid,
132 course_instance_id: Uuid,
133}
134
135pub async fn seed_credit_registration(
136 app_config: &ApplicationConfiguration,
137 common_course_data: CommonCourseData,
138) -> Result<Uuid> {
139 let CommonCourseData {
140 db_pool,
141 organization_id: org,
142 teacher_user_id,
143 base_url,
144 ..
145 } = common_course_data;
146
147 let mut conn = db_pool.acquire().await?;
148 let cx = SeedContext {
149 teacher: teacher_user_id,
150 org,
151 base_course_ns: SUOTAR_COURSE_ID,
152 };
153
154 info!("inserting credit registration students");
155 let students = seed_linked_students(&mut conn, &cx).await?;
156
157 info!("inserting credit registration courses");
158 let suotar = seed_general_course(
159 &mut conn,
160 app_config,
161 &cx,
162 GeneralCourse {
163 name: "Credit registration via Suotar",
164 slug: SUOTAR_COURSE_SLUG,
165 course_id: SUOTAR_COURSE_ID,
166 course_code: CRS_101,
167 has_chapter: true,
169 },
170 )
171 .await?;
172 let suotar_b = seed_general_course(
173 &mut conn,
174 app_config,
175 &cx,
176 GeneralCourse {
177 name: "Credit registration via Suotar B",
178 slug: SUOTAR_B_COURSE_SLUG,
179 course_id: SUOTAR_B_COURSE_ID,
180 course_code: CRS_B_101,
181 has_chapter: false,
182 },
183 )
184 .await?;
185 let lanes = HashMap::from([(CRS_101, suotar), (CRS_B_101, suotar_b)]);
186
187 seed_old_flow_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
188 seed_certificate_detour_course(
189 &mut conn,
190 app_config,
191 org,
192 teacher_user_id,
193 students.get(&STUDENT_7)?,
194 students.get(&STUDENT_8)?,
195 )
196 .await?;
197 seed_import_outcomes_course(
198 &mut conn,
199 app_config,
200 org,
201 teacher_user_id,
202 students.get(&STUDENT_8)?,
203 )
204 .await?;
205 seed_grade_improvement_course(
206 &mut conn,
207 app_config,
208 org,
209 teacher_user_id,
210 students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
211 )
212 .await?;
213 seed_admin_course(
214 &mut conn,
215 app_config,
216 org,
217 teacher_user_id,
218 students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
219 )
220 .await?;
221 seed_states_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
222 seed_retry_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
223
224 info!("inserting credit registration completions");
225 for lane in &LANE_COMPLETIONS {
226 let course = lanes
227 .get(lane.course_code)
228 .ok_or_else(|| anyhow::anyhow!("no general course has code {}", lane.course_code))?;
229 let student = students.get(lane.student)?;
230 enroll(
231 &mut conn,
232 student.user_id,
233 course.course_id,
234 course.course_instance_id,
235 )
236 .await?;
237 let module_id = default_module_id(&mut conn, course.course_id).await?;
238 let completion_id =
239 seed_eligible_completion(&mut conn, student, module_id, course.course_id, None).await?;
240 if lane.student.student_number == STUDENT_6.student_number {
243 credit_registration_enrolment_check_signals::record_check_request(
244 &mut conn,
245 completion_id,
246 EnrolmentCheckSource::StudentRequest,
247 )
248 .await?;
249 }
250 }
251
252 let link_claimer = insert_student(
253 &mut conn,
254 cx.v5(b"user:link-claimer"),
255 LINK_CLAIMER_EMAIL,
256 "Zzyzx",
257 "Claimer",
258 )
259 .await?;
260 let profile_empty_user_id = users::get_by_email(&mut conn, PROFILE_EMPTY_EMAIL)
261 .await?
262 .id;
263 let replaced_attempts_holder = students.get(&STUDENT_6)?;
264 for user_id in [
265 link_claimer.user_id,
266 profile_empty_user_id,
267 replaced_attempts_holder.user_id,
268 ] {
269 enroll(
270 &mut conn,
271 user_id,
272 suotar.course_id,
273 suotar.course_instance_id,
274 )
275 .await?;
276 }
277
278 info!("inserting credit registration linking tokens");
279 seed_linking_tokens(
280 &mut conn,
281 &cx,
282 suotar.course_id,
283 replaced_attempts_holder.user_id,
284 )
285 .await?;
286
287 info!("inserting credit registration ledger history");
288 seed_superseded_attempt_pair(&mut conn, replaced_attempts_holder, suotar).await?;
289
290 study_registry_registrars::insert(
291 &mut conn,
292 PKeyPolicy::Fixed(PULL_REGISTRAR_ID),
293 "Credit registration system tests (pull)",
294 PULL_REGISTRAR_SECRET_KEY,
295 )
296 .await?;
297
298 info!("inserting credit registration admin actions");
299 seed_admin_actions(&mut conn, &cx, suotar.course_id, teacher_user_id).await?;
300
301 push_mock_suotar_world(&base_url).await?;
302
303 Ok(SUOTAR_COURSE_ID)
304}
305
306async fn seed_linked_students(conn: &mut PgConnection, cx: &SeedContext) -> Result<LinkedStudents> {
309 let mut by_student_number = HashMap::new();
310 for fixture in [&STUDENT_6, &STUDENT_7, &STUDENT_8] {
311 let email = account_email(fixture)?;
312 let user_id = users::get_by_email(conn, email).await?.id;
313 link_student_number(
314 conn,
315 cx,
316 fixture,
317 user_id,
318 StudentNumberVerificationMethod::EmailedLink,
319 )
320 .await?;
321 by_student_number.insert(
322 fixture.student_number,
323 SeededStudent {
324 user_id,
325 email: email.to_string(),
326 },
327 );
328 }
329 for fixture in [
330 &CREDIT_REGISTRATION_STUDENT_1,
331 &CREDIT_REGISTRATION_STUDENT_2,
332 &CREDIT_REGISTRATION_STUDENT_3,
333 &CREDIT_REGISTRATION_STUDENT_4,
334 &CREDIT_REGISTRATION_STUDENT_5,
335 &CREDIT_REGISTRATION_STUDENT_6,
336 ] {
337 let email = account_email(fixture)?;
338 let student = insert_student(
339 conn,
340 cx.v5(email.as_bytes()),
341 email,
342 fixture.first_names,
343 fixture.last_name,
344 )
345 .await?;
346 let verified_via = if fixture.student_number == CREDIT_REGISTRATION_STUDENT_2.student_number
347 {
348 StudentNumberVerificationMethod::AdminManual
349 } else {
350 StudentNumberVerificationMethod::EmailedLink
351 };
352 link_student_number(conn, cx, fixture, student.user_id, verified_via).await?;
353 by_student_number.insert(fixture.student_number, student);
354 }
355 Ok(LinkedStudents { by_student_number })
356}
357
358fn account_email(fixture: &MockPersonFixture) -> Result<&'static str> {
359 fixture
360 .account_email
361 .ok_or_else(|| anyhow::anyhow!("{} has no account", fixture.student_number))
362}
363
364struct GeneralCourse {
365 name: &'static str,
366 slug: &'static str,
367 course_id: Uuid,
368 course_code: &'static str,
369 has_chapter: bool,
370}
371
372async fn seed_general_course(
374 conn: &mut PgConnection,
375 app_config: &ApplicationConfiguration,
376 cx: &SeedContext,
377 general: GeneralCourse,
378) -> Result<SeededCourse> {
379 let cx = SeedContext {
380 teacher: cx.teacher,
381 org: cx.org,
382 base_course_ns: general.course_id,
383 };
384 let mut module = ModuleBuilder::new()
385 .order(0)
386 .ects(5.0)
387 .uh_course_code(general.course_code.to_string())
388 .credit_registration(credit_registration_config(general.course_code));
389 if general.has_chapter {
390 module = module.chapter(
391 ChapterBuilder::new(1, "Registering credits")
392 .opens(Utc::now())
393 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
394 .page(
395 PageBuilder::new("/chapter-1/page-1", "How registration works").block(
396 paragraph(
397 "Completing this module registers credits into Sisu.",
398 cx.v5(b"page:1:1:block"),
399 ),
400 ),
401 ),
402 );
403 }
404 let (course, instance, _) = CourseBuilder::new(general.name, general.slug)
405 .desc("Fixture course for the credit registration system tests.")
406 .course_id(general.course_id)
407 .role(cx.teacher, UserRole::Teacher)
408 .instance(instance_config(cx.v5(b"instance:suotar")))
409 .module(module)
410 .seed(conn, app_config, &cx)
411 .await?;
412 Ok(SeededCourse {
413 course_id: course.id,
414 course_instance_id: instance.id,
415 })
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) -> CreditRegistrationSeed {
453 CreditRegistrationSeed {
454 enrolment_link: Some(format!(
455 "https://www.avoin.helsinki.fi/palvelut/esittely.aspx?s=seed-{course_code}"
456 )),
457 paused_reason: None,
458 registers_seeded_completions: true,
459 }
460}
461
462fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
463 CourseInstanceConfig {
464 name: None,
465 description: None,
466 support_email: None,
467 teacher_in_charge_name: "admin".to_string(),
468 teacher_in_charge_email: "admin@example.com".to_string(),
469 opening_time: None,
470 closing_time: None,
471 instance_id: Some(instance_id),
472 }
473}
474
475async fn seed_old_flow_course(
482 conn: &mut PgConnection,
483 app_config: &ApplicationConfiguration,
484 org: Uuid,
485 teacher_user_id: Uuid,
486 students: &LinkedStudents,
487) -> Result<()> {
488 let cx = SeedContext {
489 teacher: teacher_user_id,
490 org,
491 base_course_ns: OLD_FLOW_COURSE_ID,
492 };
493 let registrar_id = get_or_create_default_registrar(conn).await?;
494 let still_legacy = students.get(&STUDENT_7)?;
495 let predates_opt_in = students.get(&STUDENT_8)?;
496
497 let (course, instance, _) =
498 CourseBuilder::new("Credit registration old flow", OLD_FLOW_COURSE_SLUG)
499 .desc("Fixture course left on the legacy open university registration flow.")
500 .course_id(OLD_FLOW_COURSE_ID)
501 .instance(instance_config(cx.v5(b"instance:old-flow")))
502 .module(
503 ModuleBuilder::new()
504 .order(0)
505 .ects(5.0)
506 .uh_course_code(CRS_OLD_101.to_string())
507 .completion(
508 CompletionBuilder::new(still_legacy.user_id)
509 .email(still_legacy.email.clone())
510 .grade(3)
511 .passed(true)
512 .prerequisite_modules_completed(true),
513 ),
514 )
515 .module(
516 ModuleBuilder::new()
517 .order(1)
518 .name("Opted into Suotar")
519 .ects(5.0)
520 .uh_course_code(CRS_OLD_102.to_string())
521 .credit_registration(CreditRegistrationSeed {
522 registers_seeded_completions: false,
523 ..credit_registration_config(CRS_OLD_102)
524 })
525 .default_registrar(registrar_id)
526 .completion(
527 CompletionBuilder::new(predates_opt_in.user_id)
528 .email(predates_opt_in.email.clone())
529 .grade(3)
530 .passed(true)
531 .prerequisite_modules_completed(true)
532 .registered(
533 CompletionRegisteredBuilder::new()
534 .real_student_number(STUDENT_8.student_number),
535 ),
536 ),
537 )
538 .seed(conn, app_config, &cx)
539 .await?;
540
541 for student in [still_legacy, predates_opt_in] {
542 enroll(conn, student.user_id, course.id, instance.id).await?;
543 }
544 Ok(())
545}
546
547async fn seed_certificate_detour_course(
556 conn: &mut PgConnection,
557 app_config: &ApplicationConfiguration,
558 org: Uuid,
559 teacher_user_id: Uuid,
560 student: &SeededStudent,
561 failed_save_student: &SeededStudent,
562) -> Result<()> {
563 let cx = SeedContext {
564 teacher: teacher_user_id,
565 org,
566 base_course_ns: CERTIFICATE_DETOUR_COURSE_ID,
567 };
568
569 let (course, instance, module) = CourseBuilder::new(
570 "Credit registration certificate detour",
571 CERTIFICATE_DETOUR_COURSE_SLUG,
572 )
573 .desc("Fixture course whose open university module also offers a certificate.")
574 .course_id(CERTIFICATE_DETOUR_COURSE_ID)
575 .instance(instance_config(cx.v5(b"instance:certificate-detour")))
576 .module(
577 ModuleBuilder::new()
578 .order(0)
579 .ects(5.0)
580 .uh_course_code(CRS_DETOUR_101.to_string())
581 .completion(
582 CompletionBuilder::new(student.user_id)
583 .email(student.email.clone())
584 .grade(3)
585 .passed(true)
586 .prerequisite_modules_completed(true),
587 )
588 .completion(
589 CompletionBuilder::new(failed_save_student.user_id)
590 .email(failed_save_student.email.clone())
591 .grade(3)
592 .passed(true)
593 .prerequisite_modules_completed(true),
594 ),
595 )
596 .certificate_config("svgs/certificate-background.svg", None)
597 .seed(conn, app_config, &cx)
598 .await?;
599
600 course_modules::update_certification_enabled(conn, module.id, true, teacher_user_id).await?;
601 open_university_registration_links::upsert(conn, CRS_DETOUR_101, "https://www.example.com")
602 .await?;
603 for enrolled_student in [student, failed_save_student] {
604 enroll(conn, enrolled_student.user_id, course.id, instance.id).await?;
605 }
606 Ok(())
607}
608
609async fn seed_admin_course(
615 conn: &mut PgConnection,
616 app_config: &ApplicationConfiguration,
617 org: Uuid,
618 teacher_user_id: Uuid,
619 linked: &SeededStudent,
620) -> Result<()> {
621 let cx = SeedContext {
622 teacher: teacher_user_id,
623 org,
624 base_course_ns: ADMIN_COURSE_ID,
625 };
626 let (course, instance, module) =
627 CourseBuilder::new("Credit registration admin", ADMIN_COURSE_SLUG)
628 .desc("Fixture course for the admin dashboard's account linking views.")
629 .course_id(ADMIN_COURSE_ID)
630 .role(teacher_user_id, UserRole::Teacher)
631 .instance(instance_config(cx.v5(b"instance:admin")))
632 .module(
633 ModuleBuilder::new()
634 .order(0)
635 .ects(5.0)
636 .uh_course_code(CRS_ADMIN_101.to_string())
637 .credit_registration(credit_registration_config(CRS_ADMIN_101)),
638 )
639 .seed(conn, app_config, &cx)
640 .await?;
641
642 enroll(conn, linked.user_id, course.id, instance.id).await?;
643 seed_eligible_completion(conn, linked, module.id, course.id, None).await?;
644
645 for fixture in [&ADMIN_STALE, &TEACHER_RESEND_CAPPED] {
646 for (weeks_ago, suffix) in (1..).zip(MAILED_ADDRESS_SUFFIXES) {
647 let address = format!("{suffix}{}", fixture.sisu_email);
648 let claimed = credit_registration_account_linking_emails::claim_send_slot(
649 conn,
650 &NewAccountLinkingEmail {
651 student_number: DbSecret::new(fixture.student_number),
652 sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
653 course_id: course.id,
654 emailed_to: DbSecret::new(address.clone()),
655 student_number_verification_token_id: None,
656 email_delivery_id: None,
657 },
658 )
659 .await?;
660 let Some(claimed) = claimed else {
661 anyhow::bail!("the dedup key refused a seeded linking mail to {address}");
662 };
663 credit_registration_account_linking_emails::backdate(
666 conn,
667 claimed,
668 Duration::weeks(weeks_ago),
669 )
670 .await?;
671 }
672 }
673 Ok(())
674}
675
676async fn seed_states_course(
683 conn: &mut PgConnection,
684 app_config: &ApplicationConfiguration,
685 org: Uuid,
686 teacher_user_id: Uuid,
687 students: &LinkedStudents,
688) -> Result<()> {
689 let cx = SeedContext {
690 teacher: teacher_user_id,
691 org,
692 base_course_ns: STATES_COURSE_ID,
693 };
694 let mut course = CourseBuilder::new("Credit registration states", STATES_COURSE_SLUG)
695 .desc("Fixture course holding one frozen registration per state and per error code.")
696 .course_id(STATES_COURSE_ID)
697 .role(teacher_user_id, UserRole::Teacher)
698 .instance(instance_config(cx.v5(b"instance:states")));
699 for (order, course_code) in STATES_COURSE_CODES.iter().enumerate() {
700 let mut module = ModuleBuilder::new()
701 .order(order as i32)
702 .ects(5.0)
703 .uh_course_code(course_code.to_string())
704 .credit_registration(CreditRegistrationSeed {
705 paused_reason: Some(
706 "Seeded fixture: these rows are read by the teacher and admin views and must not move."
707 .to_string(),
708 ),
709 ..credit_registration_config(course_code)
710 });
711 if order > 0 {
712 module = module.name(format!("Module {course_code}"));
713 }
714 course = course.module(module);
715 }
716 let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
717 let seeded = SeededCourse {
718 course_id: course.id,
719 course_instance_id: instance.id,
720 };
721
722 let holders = [
723 students.get(&STUDENT_7)?,
724 students.get(&STUDENT_8)?,
725 students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
726 students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
727 students.get(&CREDIT_REGISTRATION_STUDENT_3)?,
728 students.get(&CREDIT_REGISTRATION_STUDENT_4)?,
729 students.get(&CREDIT_REGISTRATION_STUDENT_5)?,
730 ];
731 for holder in holders {
732 enroll(conn, holder.user_id, course.id, instance.id).await?;
733 }
734 let module_ids = module_ids_by_course_code(conn, course.id).await?;
735
736 let rows = CreditRegistrationState::ALL
738 .iter()
739 .map(|state| (*state, None))
740 .chain(
741 CreditRegistrationErrorCode::ALL
742 .iter()
743 .map(|code| (CreditRegistrationState::FailedPermanent, Some(*code))),
744 );
745 for (index, (state, error_code)) in rows.enumerate() {
746 let holder = holders[index % holders.len()];
747 let course_code = STATES_COURSE_CODES
748 .get(index / holders.len())
749 .ok_or_else(|| anyhow::anyhow!("the states grid has too few modules"))?;
750 let module_id = *module_ids
751 .get(*course_code)
752 .ok_or_else(|| anyhow::anyhow!("no states module has code {course_code}"))?;
753 seed_frozen_registration(conn, &cx, seeded, module_id, holder, state, error_code).await?;
754 }
755 Ok(())
756}
757
758async fn module_ids_by_course_code(
759 conn: &mut PgConnection,
760 course_id: Uuid,
761) -> Result<HashMap<String, Uuid>> {
762 Ok(course_modules::get_by_course_id(conn, course_id)
763 .await?
764 .into_iter()
765 .filter_map(|module| module.uh_course_code.map(|code| (code, module.id)))
766 .collect())
767}
768
769async fn seed_retry_course(
777 conn: &mut PgConnection,
778 app_config: &ApplicationConfiguration,
779 org: Uuid,
780 teacher_user_id: Uuid,
781 students: &LinkedStudents,
782) -> Result<()> {
783 let cx = SeedContext {
784 teacher: teacher_user_id,
785 org,
786 base_course_ns: RETRY_COURSE_ID,
787 };
788 let (course, instance, module) =
789 CourseBuilder::new("Credit registration retry", RETRY_COURSE_SLUG)
790 .desc("Fixture course holding the registrations a teacher retries, and the ones they cannot.")
791 .course_id(RETRY_COURSE_ID)
792 .role(teacher_user_id, UserRole::Teacher)
793 .instance(instance_config(cx.v5(b"instance:retry")))
794 .module(
795 ModuleBuilder::new()
796 .order(0)
797 .ects(5.0)
798 .uh_course_code(CRS_RETRY_101.to_string())
799 .credit_registration(CreditRegistrationSeed {
800 paused_reason: Some(
801 "Seeded fixture: the retry specs read these rows and the workers must not move them."
802 .to_string(),
803 ),
804 ..credit_registration_config(CRS_RETRY_101)
805 }),
806 )
807 .seed(conn, app_config, &cx)
808 .await?;
809 let seeded = SeededCourse {
810 course_id: course.id,
811 course_instance_id: instance.id,
812 };
813
814 for (fixture, state) in [
818 (
819 &CREDIT_REGISTRATION_STUDENT_1,
820 CreditRegistrationState::FailedPermanent,
821 ),
822 (
823 &CREDIT_REGISTRATION_STUDENT_2,
824 CreditRegistrationState::FailedPermanent,
825 ),
826 (
827 &CREDIT_REGISTRATION_STUDENT_3,
828 CreditRegistrationState::SubmissionUncertain,
829 ),
830 (
831 &CREDIT_REGISTRATION_STUDENT_4,
832 CreditRegistrationState::Cancelled,
833 ),
834 ] {
835 let holder = students.get(fixture)?;
836 enroll(conn, holder.user_id, course.id, instance.id).await?;
837 seed_frozen_registration(conn, &cx, seeded, module.id, holder, state, None).await?;
838 }
839 Ok(())
840}
841
842async fn seed_frozen_registration(
844 conn: &mut PgConnection,
845 cx: &SeedContext,
846 course: SeededCourse,
847 course_module_id: Uuid,
848 student: &SeededStudent,
849 state: CreditRegistrationState,
850 error_code: Option<CreditRegistrationErrorCode>,
851) -> Result<()> {
852 let completion_id =
853 seed_eligible_completion(conn, student, course_module_id, course.course_id, None).await?;
854 let id = credit_registrations::insert(
855 conn,
856 PKeyPolicy::Fixed(
857 cx.v5(format!("credit-registration:{}:{course_module_id}", student.email).as_bytes()),
858 ),
859 &NewCreditRegistration {
860 course_module_completion_id: completion_id,
861 user_id: student.user_id,
862 course_id: course.course_id,
863 course_module_id,
864 course_instance_id: course.course_instance_id,
865 attempt_number: 1,
866 },
867 Some("Seeded fixture"),
868 )
869 .await?;
870 credit_registrations::transition(
871 conn,
872 id,
873 &Transition {
874 error_code,
875 needs_admin_attention: error_code.map(|_| credit_registrations::AdminAttention::Raise),
876 ..Transition::planted(state)
877 },
878 )
879 .await?;
880 Ok(())
881}
882
883async fn seed_import_outcomes_course(
886 conn: &mut PgConnection,
887 app_config: &ApplicationConfiguration,
888 org: Uuid,
889 teacher_user_id: Uuid,
890 student: &SeededStudent,
891) -> Result<()> {
892 let cx = SeedContext {
893 teacher: teacher_user_id,
894 org,
895 base_course_ns: IMPORT_OUTCOMES_COURSE_ID,
896 };
897 let mut course = CourseBuilder::new(
898 "Credit registration import outcomes",
899 IMPORT_OUTCOMES_COURSE_SLUG,
900 )
901 .desc("Fixture course whose modules each provoke one Sisu import error.")
902 .course_id(IMPORT_OUTCOMES_COURSE_ID)
903 .instance(instance_config(cx.v5(b"instance:import-outcomes")));
904 for (order, course_code) in IMPORT_OUTCOME_COURSE_CODES.iter().enumerate() {
905 let mut module = ModuleBuilder::new()
906 .order(order as i32)
907 .ects(5.0)
908 .uh_course_code(course_code.to_string())
909 .credit_registration(credit_registration_config(course_code));
910 if order > 0 {
911 module = module.name(format!("Module {course_code}"));
912 }
913 course = course.module(module);
914 }
915 let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
916 enroll(conn, student.user_id, course.id, instance.id).await?;
917 for module in course_modules::get_by_course_id(conn, course.id).await? {
918 seed_eligible_completion(conn, student, module.id, course.id, None).await?;
919 }
920 Ok(())
921}
922
923async fn seed_grade_improvement_course(
924 conn: &mut PgConnection,
925 app_config: &ApplicationConfiguration,
926 org: Uuid,
927 teacher_user_id: Uuid,
928 student: &SeededStudent,
929) -> Result<()> {
930 let cx = SeedContext {
931 teacher: teacher_user_id,
932 org,
933 base_course_ns: GRADE_IMPROVEMENT_COURSE_ID,
934 };
935 let (course, instance, module) = CourseBuilder::new(
936 "Credit registration grade improvement",
937 GRADE_IMPROVEMENT_COURSE_SLUG,
938 )
939 .desc("Fixture course whose module is graded rather than pass/fail.")
940 .course_id(GRADE_IMPROVEMENT_COURSE_ID)
941 .instance(instance_config(cx.v5(b"instance:grade-improvement")))
942 .module(
943 ModuleBuilder::new()
944 .order(0)
945 .ects(5.0)
946 .uh_course_code(CRS_GRADED_101.to_string())
947 .credit_registration(credit_registration_config(CRS_GRADED_101)),
948 )
949 .seed(conn, app_config, &cx)
950 .await?;
951 enroll(conn, student.user_id, course.id, instance.id).await?;
952 seed_eligible_completion(conn, student, module.id, course.id, Some(3)).await?;
953 Ok(())
954}
955
956async fn link_student_number(
957 conn: &mut PgConnection,
958 cx: &SeedContext,
959 fixture: &MockPersonFixture,
960 user_id: Uuid,
961 verified_via: StudentNumberVerificationMethod,
962) -> Result<()> {
963 let is_admin_manual = verified_via == StudentNumberVerificationMethod::AdminManual;
964 verified_student_numbers::insert(
965 conn,
966 PKeyPolicy::Fixed(cx.v5(format!("verified:{}", fixture.student_number).as_bytes())),
967 &NewVerifiedStudentNumber {
968 user_id,
969 student_number: DbSecret::new(fixture.student_number),
970 sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
971 first_names: Some(DbSecret::new(fixture.first_names)),
972 last_name: Some(DbSecret::new(fixture.last_name)),
973 verified_via,
974 verified_via_email: (!is_admin_manual).then(|| DbSecret::new(fixture.sisu_email)),
975 linked_by_user_id: is_admin_manual.then_some(cx.teacher),
976 link_reason: is_admin_manual
977 .then(|| "Seeded fixture: the address Sisu holds rejects our mail.".to_string()),
978 verified_from_course_id: None,
979 },
980 )
981 .await?;
982 Ok(())
983}
984
985async fn enroll(
988 conn: &mut PgConnection,
989 user_id: Uuid,
990 course_id: Uuid,
991 course_instance_id: Uuid,
992) -> Result<()> {
993 course_instance_enrollments::insert_enrollment_and_set_as_current(
994 conn,
995 NewCourseInstanceEnrollment {
996 user_id,
997 course_id,
998 course_instance_id,
999 },
1000 )
1001 .await?;
1002 user_ai_usage_notice_acknowledgements::acknowledge(conn, user_id, course_id).await?;
1003 Ok(())
1004}
1005
1006async fn default_module_id(conn: &mut PgConnection, course_id: Uuid) -> Result<Uuid> {
1007 Ok(course_modules::get_default_by_course_id(conn, course_id)
1008 .await?
1009 .id)
1010}
1011
1012async fn seed_eligible_completion(
1015 conn: &mut PgConnection,
1016 student: &SeededStudent,
1017 course_module_id: Uuid,
1018 course_id: Uuid,
1019 grade: Option<i32>,
1020) -> Result<Uuid> {
1021 let completion_id = course_module_completions::insert_seed_row(
1022 conn,
1023 &NewCourseModuleCompletionSeed {
1024 course_id,
1025 course_module_id,
1026 user_id: student.user_id,
1027 completion_date: Some(Utc::now() - Duration::days(1)),
1028 completion_language: Some("en-US".to_string()),
1029 eligible_for_ects: Some(true),
1030 email: Some(student.email.clone()),
1031 grade,
1032 passed: Some(true),
1033 prerequisite_modules_completed: Some(true),
1034 needs_to_be_reviewed: Some(false),
1035 register_credits_via_suotar: true,
1036 },
1037 )
1038 .await?;
1039 Ok(completion_id)
1040}
1041
1042async fn insert_student(
1043 conn: &mut PgConnection,
1044 user_id: Uuid,
1045 email: &str,
1046 first_name: &str,
1047 last_name: &str,
1048) -> Result<SeededStudent> {
1049 let user_id = users::insert(
1050 conn,
1051 PKeyPolicy::Fixed(user_id),
1052 email,
1053 Some(first_name),
1054 Some(last_name),
1055 )
1056 .await?;
1057 user_details::update_user_country(conn, user_id, "fi").await?;
1058 let password = email
1061 .split('@')
1062 .next()
1063 .expect("split always yields one element");
1064 let hash = hash_password(&SecretString::new(password.to_string().into()))
1065 .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
1066 upsert_user_password(conn, user_id, &hash).await?;
1067 Ok(SeededStudent {
1068 user_id,
1069 email: email.to_string(),
1070 })
1071}
1072
1073async fn seed_linking_tokens(
1076 conn: &mut PgConnection,
1077 cx: &SeedContext,
1078 course_id: Uuid,
1079 claimed_by_user_id: Uuid,
1080) -> Result<()> {
1081 let now = Utc::now();
1082 student_number_verification_tokens::insert_seed_row(
1083 conn,
1084 PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
1085 &SeedStudentNumberVerificationToken {
1086 token: LINKING_TOKEN_VALID.to_string(),
1087 student_number: LINK_VALID.student_number.to_string(),
1088 sisu_person_id: LINK_VALID.sisu_person_id(),
1089 first_names: Some(LINK_VALID.first_names.to_string()),
1090 last_name: Some(LINK_VALID.last_name.to_string()),
1091 emailed_to: LINK_VALID.sisu_email.to_string(),
1092 course_id: Some(course_id),
1093 expires_at: now + Duration::days(14),
1094 used_at: None,
1095 claimed_by_user_id: None,
1096 },
1097 )
1098 .await?;
1099 student_number_verification_tokens::insert_seed_row(
1100 conn,
1101 PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
1102 &SeedStudentNumberVerificationToken {
1103 token: LINKING_TOKEN_EXPIRED.to_string(),
1104 student_number: LINK_EXPIRED.student_number.to_string(),
1105 sisu_person_id: LINK_EXPIRED.sisu_person_id(),
1106 first_names: Some(LINK_EXPIRED.first_names.to_string()),
1107 last_name: Some(LINK_EXPIRED.last_name.to_string()),
1108 emailed_to: LINK_EXPIRED.sisu_email.to_string(),
1109 course_id: Some(course_id),
1110 expires_at: now - Duration::days(1),
1111 used_at: None,
1112 claimed_by_user_id: None,
1113 },
1114 )
1115 .await?;
1116 student_number_verification_tokens::insert_seed_row(
1117 conn,
1118 PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
1119 &SeedStudentNumberVerificationToken {
1120 token: LINKING_TOKEN_ALREADY_USED.to_string(),
1121 student_number: LINK_USED.student_number.to_string(),
1122 sisu_person_id: LINK_USED.sisu_person_id(),
1123 first_names: Some(LINK_USED.first_names.to_string()),
1124 last_name: Some(LINK_USED.last_name.to_string()),
1125 emailed_to: LINK_USED.sisu_email.to_string(),
1126 course_id: Some(course_id),
1127 expires_at: now + Duration::days(14),
1128 used_at: Some(now - Duration::hours(1)),
1129 claimed_by_user_id: Some(claimed_by_user_id),
1130 },
1131 )
1132 .await?;
1133 student_number_verification_tokens::insert_seed_row(
1134 conn,
1135 PKeyPolicy::Fixed(cx.v5(b"linking-token:conflict")),
1136 &SeedStudentNumberVerificationToken {
1137 token: LINKING_TOKEN_CONFLICT.to_string(),
1138 student_number: STUDENT_6.student_number.to_string(),
1139 sisu_person_id: STUDENT_6.sisu_person_id(),
1140 first_names: Some(STUDENT_6.first_names.to_string()),
1141 last_name: Some(STUDENT_6.last_name.to_string()),
1142 emailed_to: STUDENT_6.sisu_email.to_string(),
1143 course_id: Some(course_id),
1144 expires_at: now + Duration::days(14),
1145 used_at: None,
1146 claimed_by_user_id: None,
1147 },
1148 )
1149 .await?;
1150 Ok(())
1151}
1152
1153async fn seed_superseded_attempt_pair(
1156 conn: &mut PgConnection,
1157 student: &SeededStudent,
1158 course: SeededCourse,
1159) -> Result<()> {
1160 let course_module_id = default_module_id(conn, course.course_id).await?;
1161 let completion_id = course_module_completions::insert_seed_row(
1162 conn,
1163 &NewCourseModuleCompletionSeed {
1164 course_id: course.course_id,
1165 course_module_id,
1166 user_id: student.user_id,
1167 completion_date: Some(Utc::now() - Duration::days(20)),
1168 completion_language: Some("en-US".to_string()),
1169 eligible_for_ects: Some(true),
1170 email: Some(student.email.clone()),
1171 grade: Some(4),
1172 passed: Some(true),
1173 prerequisite_modules_completed: Some(true),
1174 needs_to_be_reviewed: Some(false),
1175 register_credits_via_suotar: true,
1176 },
1177 )
1178 .await?;
1179
1180 let mut tx = conn.begin().await?;
1183 let attempt_1 = insert_registered_attempt(
1184 &mut tx,
1185 SUPERSEDED_ATTEMPT_1_ID,
1186 completion_id,
1187 student.user_id,
1188 course,
1189 course_module_id,
1190 1,
1191 "3",
1192 )
1193 .await?;
1194 credit_registrations::mark_superseded(&mut tx, attempt_1, SUPERSEDED_ATTEMPT_2_ID).await?;
1195 insert_registered_attempt(
1196 &mut tx,
1197 SUPERSEDED_ATTEMPT_2_ID,
1198 completion_id,
1199 student.user_id,
1200 course,
1201 course_module_id,
1202 2,
1203 "4",
1204 )
1205 .await?;
1206 tx.commit().await?;
1207 Ok(())
1208}
1209
1210#[allow(clippy::too_many_arguments)]
1211async fn insert_registered_attempt(
1212 conn: &mut PgConnection,
1213 id: Uuid,
1214 course_module_completion_id: Uuid,
1215 user_id: Uuid,
1216 course: SeededCourse,
1217 course_module_id: Uuid,
1218 attempt_number: i32,
1219 grade_id: &str,
1220) -> Result<Uuid> {
1221 let id = credit_registrations::insert(
1222 conn,
1223 PKeyPolicy::Fixed(id),
1224 &NewCreditRegistration {
1225 course_module_completion_id,
1226 user_id,
1227 course_id: course.course_id,
1228 course_module_id,
1229 course_instance_id: course.course_instance_id,
1230 attempt_number,
1231 },
1232 Some("Seeded fixture"),
1233 )
1234 .await?;
1235 credit_registrations::set_payload_snapshot(
1236 conn,
1237 id,
1238 &PayloadSnapshot {
1239 student_number: DbSecret::new(STUDENT_6.student_number),
1240 sisu_person_id: Some(DbSecret::new(STUDENT_6.sisu_person_id())),
1241 uh_course_code: CRS_101.to_string(),
1242 selected_enrolment_id: Some(format!("otm-{}-degree", STUDENT_6.student_number)),
1243 selected_enrolment_kind: Some("degree".to_string()),
1244 selected_enrolment_realisation_id: Some("hy-opt-cur-090009011".to_string()),
1245 selected_enrolment_realisation_name: Some(serde_json::json!({
1246 "fi": "Rekisteröinnin testitoteutus",
1247 "en": "Registration test realisation",
1248 "sv": null,
1249 })),
1250 attained_at: Utc::now() - Duration::days(20),
1251 attainment_language: "en".to_string(),
1252 grade_scale_id: "sis-0-5".to_string(),
1253 grade_id: grade_id.to_string(),
1254 credits: 5.0,
1255 },
1256 )
1257 .await?;
1258 credit_registrations::transition(
1259 conn,
1260 id,
1261 &Transition::planted(CreditRegistrationState::Registered),
1262 )
1263 .await?;
1264 Ok(id)
1265}
1266
1267async fn seed_admin_actions(
1270 conn: &mut PgConnection,
1271 cx: &SeedContext,
1272 course_id: Uuid,
1273 teacher_user_id: Uuid,
1274) -> Result<()> {
1275 let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
1276 .await?
1277 .id;
1278 credit_registration_admin_actions::record(
1279 conn,
1280 &NewCreditRegistrationAdminAction {
1281 target_id: Some(SUPERSEDED_ATTEMPT_1_ID),
1282 reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
1283 before_state: Some(CreditRegistrationState::SubmissionUncertain),
1284 after_state: Some(CreditRegistrationState::Registered),
1285 affected_row_count: Some(1),
1286 ..NewCreditRegistrationAdminAction::new(
1287 CreditRegistrationAdminAction::TransitionItem,
1288 CreditRegistrationAdminActionTarget::CreditRegistration,
1289 admin_user_id,
1290 GLOBAL_ADMIN_ROLE,
1291 )
1292 },
1293 )
1294 .await?;
1295 credit_registration_admin_actions::record(
1296 conn,
1297 &NewCreditRegistrationAdminAction {
1298 target_id: Some(cx.v5(b"linking-token:valid")),
1299 actor_course_id: Some(course_id),
1300 reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
1301 affected_row_count: Some(1),
1302 ..NewCreditRegistrationAdminAction::new(
1303 CreditRegistrationAdminAction::ResendLinkEmail,
1304 CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
1305 teacher_user_id,
1306 COURSE_TEACHER_ROLE,
1307 )
1308 },
1309 )
1310 .await?;
1311 Ok(())
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317
1318 #[test]
1321 fn seeded_linking_tokens_are_long_enough() {
1322 for token in [
1323 LINKING_TOKEN_VALID,
1324 LINKING_TOKEN_EXPIRED,
1325 LINKING_TOKEN_ALREADY_USED,
1326 LINKING_TOKEN_CONFLICT,
1327 ] {
1328 assert!(token.len() >= 128, "token too short: {}", token.len());
1329 }
1330 }
1331}