Skip to main content

headless_lms_server/programs/seed/seed_courses/
seed_credit_registration.rs

1//! Database rows for the credit-registration (Suotar) system tests. The identities they are built
2//! from, and the matching registry world, are in [`crate::mock_suotar::fixtures`].
3//!
4//! The workers tick every phase unscoped every few seconds in the test deployment, so a fixture row
5//! nothing may move has to sit on a paused module — that is what the states course is for.
6
7use 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
59/// The certificate detour's own course. Not in `mock_suotar::fixtures` with the others: this one
60/// never reaches Suotar, so the mock registry knows nothing about it.
61pub 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
66/// A study registry registrar whose key a spec can present, so the legacy pull stream is readable
67/// from a test. Every other registrar's key is random by design.
68pub 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
71/// The seeded attempt chain, by fixed id so a spec can open the detail page without searching.
72pub 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
75/// Linking tokens for `suotar-account-linking.spec.ts`, seeded rather than mailed.
76///
77/// Each is a UUID repeated four times because `student_number_verification_token_length` requires at
78/// least 128 characters.
79pub 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);
97/// Its student number is already live on another account, so claiming it is refused without
98/// consuming the token.
99pub 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
106/// Enrolled on the general Suotar course and nothing else, for the studies page's empty state.
107const PROFILE_EMPTY_EMAIL: &str = "student5@example.com";
108
109/// A seeded student, with the deterministic id a spec navigates by.
110struct SeededStudent {
111    user_id: Uuid,
112    email: String,
113}
114
115/// The accounts holding a linked number, by the fixture they were built from.
116struct 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/// A course with an instance, for enrolling students after the course itself exists.
129#[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            // suotar-in-course-banner.spec.ts needs a chapter page it can actually read.
168            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        // A first check a day out would leave `student6`'s studies page asking it to act, and
241        // suotar-student-profile asserts that page is clean.
242        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
306/// Creates the credit-registration accounts and links every seeded student to its fixture's number.
307/// `student6`–`student8` already exist: the general user seed creates them.
308async 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
372/// One of the courses [`LANE_COMPLETIONS`] allocates: a single working Suotar module.
373async 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
418/// Aligns the mock Suotar's world with the rows just written. Nothing is cleared first: the mock
419/// installs under a fresh generation and flips the pointer last.
420async 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        // A worldless mock surfaces as baffling failures a hundred specs later.
445        anyhow::bail!("pushing the mock Suotar world to {url} failed with {status}: {body}");
446    }
447    Ok(())
448}
449
450/// An enrolment link unique to the module's course code, without which the config check flags the
451/// module, and its seeded completions on the push path.
452fn 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
475/// Owned by `suotar-old-flow-coexistence.spec.ts`.
476///
477/// Module 0 stays on the legacy pull path outright. Module 1 has a Suotar configuration, but its one
478/// completion predates the push path and was already registered through the legacy pull path, so it
479/// must stay there and never get a Suotar registration. The two completions belong to different
480/// students because the spec tells them apart in the pull stream by address.
481async 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
547/// Owned by `completion-registration-certificate-detour.spec.ts`.
548///
549/// The one combination that draws the certificate detour on the old registration page: open
550/// university registration and a certificate the student can generate instead. No sample course has
551/// both, and the courses that come closest must keep the plain page they already test.
552///
553/// `failed_save_student` gets a completion of its own on the same module, so the spec's failed-save
554/// test can run independently of the test that saves a reason for `student` permanently.
555async 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
609/// The account-linking fixtures, on a course of their own, and one linked student a tick can
610/// register.
611///
612/// The stale-address list only renders a (person, course) mailed to the cap and never claimed, which
613/// takes three mails at three addresses because the dedup key is the address.
614async 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            // An override never retires a mail claimed moments ago, and the specs override these
664            // right after seeding.
665            credit_registration_account_linking_emails::backdate(
666                conn,
667                claimed,
668                Duration::weeks(weeks_ago),
669            )
670            .await?;
671        }
672    }
673    Ok(())
674}
675
676/// Every registration state, and every error code, as a frozen row.
677///
678/// An account holds one completion per module, so the rows are spread over a grid of holders and
679/// modules. The modules are paused because every phase's claim query skips paused modules;
680/// otherwise the workers in the test deployment would walk these onwards seconds after the seed
681/// finished.
682async 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    // Every code on the same state, so the explorer's error-code filter can be exercised alone.
737    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
769/// Rows a teacher may put back on the queue and rows they may not, one per holder so a spec finds
770/// each by the holder's last name.
771///
772/// Its own course rather than more rows on the states course, because a bulk retry sweeps a whole
773/// course and would leave the states fixture with no `failed_permanent` row and no error codes.
774/// Paused for the same reason the states course is: a retried row has to hold still in
775/// `ready_to_submit` long enough for the spec to read it.
776async 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    // The cancelled row is not a failure, so no retry of any shape moves it:
815    // `suotar-teacher-views.spec.ts` reads it both as the refusal and as the row whose state it
816    // asserts is unchanged.
817    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
842/// One completion and one ledger row parked in `state`, for `student` on `course_module_id`.
843async 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
883/// One module per failing import shape, so the spec picks its error by picking a module rather than
884/// by flipping something every other spec on the course can see.
885async 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
985/// Enrols the way a student who has been through the course's opening dialogs is: without
986/// `user_course_settings` and the AI notice acknowledgement, the course pages open on a dialog.
987async 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
1012/// A completion the pipeline will pick up. `prerequisite_modules_completed` is the trap: the builder
1013/// defaults it to false, and such a completion never leaves `pending`.
1014async 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    // The local part of the address is the password, so these students can log in through the
1059    // stored-password fallback without an entry in `authenticate_test_user`.
1060    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
1073/// `emailed_to` matches no seeded account on purpose: tokens are unbound, and bind to whoever opens
1074/// the link while logged in.
1075async 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
1153/// A registered grade-3 attempt superseded by a grade-4 one, so the admin-detail and profile specs
1154/// get an attempt chain without driving a regrade first.
1155async 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    // One transaction: the deferred foreign key lets attempt 1 point at its successor before that
1181    // row exists, which is what clears `uq_credit_registrations_completion` for the insert.
1182    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
1267/// One `global_admin` and one `course_teacher` row, so the Audit tab has content without depending
1268/// on another spec having clicked something.
1269async 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    /// `student_number_verification_token_length` requires at least 128 characters; a violation
1319    /// would otherwise surface only as a seed crash.
1320    #[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}