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    // Linked before any completion exists: a completion's registration path is decided at insert.
155    info!("inserting credit registration students");
156    let students = seed_linked_students(&mut conn, &cx).await?;
157
158    info!("inserting credit registration courses");
159    let suotar = seed_general_course(
160        &mut conn,
161        app_config,
162        &cx,
163        GeneralCourse {
164            name: "Credit registration via Suotar",
165            slug: SUOTAR_COURSE_SLUG,
166            course_id: SUOTAR_COURSE_ID,
167            course_code: CRS_101,
168            // suotar-in-course-banner.spec.ts needs a chapter page it can actually read.
169            has_chapter: true,
170        },
171    )
172    .await?;
173    let suotar_b = seed_general_course(
174        &mut conn,
175        app_config,
176        &cx,
177        GeneralCourse {
178            name: "Credit registration via Suotar B",
179            slug: SUOTAR_B_COURSE_SLUG,
180            course_id: SUOTAR_B_COURSE_ID,
181            course_code: CRS_B_101,
182            has_chapter: false,
183        },
184    )
185    .await?;
186    let lanes = HashMap::from([(CRS_101, suotar), (CRS_B_101, suotar_b)]);
187
188    seed_old_flow_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
189    seed_certificate_detour_course(
190        &mut conn,
191        app_config,
192        org,
193        teacher_user_id,
194        students.get(&STUDENT_7)?,
195        students.get(&STUDENT_8)?,
196    )
197    .await?;
198    seed_import_outcomes_course(
199        &mut conn,
200        app_config,
201        org,
202        teacher_user_id,
203        students.get(&STUDENT_8)?,
204    )
205    .await?;
206    seed_grade_improvement_course(
207        &mut conn,
208        app_config,
209        org,
210        teacher_user_id,
211        students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
212    )
213    .await?;
214    seed_admin_course(
215        &mut conn,
216        app_config,
217        org,
218        teacher_user_id,
219        students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
220    )
221    .await?;
222    seed_states_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
223    seed_retry_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
224
225    info!("inserting credit registration completions");
226    for lane in &LANE_COMPLETIONS {
227        let course = lanes
228            .get(lane.course_code)
229            .ok_or_else(|| anyhow::anyhow!("no general course has code {}", lane.course_code))?;
230        let student = students.get(lane.student)?;
231        enroll(
232            &mut conn,
233            student.user_id,
234            course.course_id,
235            course.course_instance_id,
236        )
237        .await?;
238        let module_id = default_module_id(&mut conn, course.course_id).await?;
239        let completion_id =
240            seed_eligible_completion(&mut conn, student, module_id, course.course_id, None).await?;
241        // A first check a day out would leave `student6`'s studies page asking it to act, and
242        // suotar-student-profile asserts that page is clean.
243        if lane.student.student_number == STUDENT_6.student_number {
244            credit_registration_enrolment_check_signals::record_check_request(
245                &mut conn,
246                completion_id,
247                EnrolmentCheckSource::StudentRequest,
248            )
249            .await?;
250        }
251    }
252
253    let link_claimer = insert_student(
254        &mut conn,
255        cx.v5(b"user:link-claimer"),
256        LINK_CLAIMER_EMAIL,
257        "Zzyzx",
258        "Claimer",
259    )
260    .await?;
261    let profile_empty_user_id = users::get_by_email(&mut conn, PROFILE_EMPTY_EMAIL)
262        .await?
263        .id;
264    let replaced_attempts_holder = students.get(&STUDENT_6)?;
265    for user_id in [
266        link_claimer.user_id,
267        profile_empty_user_id,
268        replaced_attempts_holder.user_id,
269    ] {
270        enroll(
271            &mut conn,
272            user_id,
273            suotar.course_id,
274            suotar.course_instance_id,
275        )
276        .await?;
277    }
278
279    info!("inserting credit registration linking tokens");
280    seed_linking_tokens(
281        &mut conn,
282        &cx,
283        suotar.course_id,
284        replaced_attempts_holder.user_id,
285    )
286    .await?;
287
288    info!("inserting credit registration ledger history");
289    seed_superseded_attempt_pair(&mut conn, replaced_attempts_holder, suotar).await?;
290
291    study_registry_registrars::insert(
292        &mut conn,
293        PKeyPolicy::Fixed(PULL_REGISTRAR_ID),
294        "Credit registration system tests (pull)",
295        PULL_REGISTRAR_SECRET_KEY,
296    )
297    .await?;
298
299    info!("inserting credit registration admin actions");
300    seed_admin_actions(&mut conn, &cx, suotar.course_id, teacher_user_id).await?;
301
302    push_mock_suotar_world(&base_url).await?;
303
304    Ok(SUOTAR_COURSE_ID)
305}
306
307/// Creates the credit-registration accounts and links every seeded student to its fixture's number.
308/// `student6`–`student8` already exist: the general user seed creates them.
309async fn seed_linked_students(conn: &mut PgConnection, cx: &SeedContext) -> Result<LinkedStudents> {
310    let mut by_student_number = HashMap::new();
311    for fixture in [&STUDENT_6, &STUDENT_7, &STUDENT_8] {
312        let email = account_email(fixture)?;
313        let user_id = users::get_by_email(conn, email).await?.id;
314        link_student_number(
315            conn,
316            cx,
317            fixture,
318            user_id,
319            StudentNumberVerificationMethod::EmailedLink,
320        )
321        .await?;
322        by_student_number.insert(
323            fixture.student_number,
324            SeededStudent {
325                user_id,
326                email: email.to_string(),
327            },
328        );
329    }
330    for fixture in [
331        &CREDIT_REGISTRATION_STUDENT_1,
332        &CREDIT_REGISTRATION_STUDENT_2,
333        &CREDIT_REGISTRATION_STUDENT_3,
334        &CREDIT_REGISTRATION_STUDENT_4,
335        &CREDIT_REGISTRATION_STUDENT_5,
336        &CREDIT_REGISTRATION_STUDENT_6,
337    ] {
338        let email = account_email(fixture)?;
339        let student = insert_student(
340            conn,
341            cx.v5(email.as_bytes()),
342            email,
343            fixture.first_names,
344            fixture.last_name,
345        )
346        .await?;
347        let verified_via = if fixture.student_number == CREDIT_REGISTRATION_STUDENT_2.student_number
348        {
349            StudentNumberVerificationMethod::AdminManual
350        } else {
351            StudentNumberVerificationMethod::EmailedLink
352        };
353        link_student_number(conn, cx, fixture, student.user_id, verified_via).await?;
354        by_student_number.insert(fixture.student_number, student);
355    }
356    Ok(LinkedStudents { by_student_number })
357}
358
359fn account_email(fixture: &MockPersonFixture) -> Result<&'static str> {
360    fixture
361        .account_email
362        .ok_or_else(|| anyhow::anyhow!("{} has no account", fixture.student_number))
363}
364
365struct GeneralCourse {
366    name: &'static str,
367    slug: &'static str,
368    course_id: Uuid,
369    course_code: &'static str,
370    has_chapter: bool,
371}
372
373/// One of the courses [`LANE_COMPLETIONS`] allocates: a single working Suotar module.
374async fn seed_general_course(
375    conn: &mut PgConnection,
376    app_config: &ApplicationConfiguration,
377    cx: &SeedContext,
378    general: GeneralCourse,
379) -> Result<SeededCourse> {
380    let cx = SeedContext {
381        teacher: cx.teacher,
382        org: cx.org,
383        base_course_ns: general.course_id,
384    };
385    let mut module = ModuleBuilder::new()
386        .order(0)
387        .ects(5.0)
388        .uh_course_code(general.course_code.to_string())
389        .credit_registration(credit_registration_config(general.course_code));
390    if general.has_chapter {
391        module = module.chapter(
392            ChapterBuilder::new(1, "Registering credits")
393                .opens(Utc::now())
394                .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
395                .page(
396                    PageBuilder::new("/chapter-1/page-1", "How registration works").block(
397                        paragraph(
398                            "Completing this module registers credits into Sisu.",
399                            cx.v5(b"page:1:1:block"),
400                        ),
401                    ),
402                ),
403        );
404    }
405    let (course, instance, _) = CourseBuilder::new(general.name, general.slug)
406        .desc("Fixture course for the credit registration system tests.")
407        .course_id(general.course_id)
408        .role(cx.teacher, UserRole::Teacher)
409        .instance(instance_config(cx.v5(b"instance:suotar")))
410        .module(module)
411        .seed(conn, app_config, &cx)
412        .await?;
413    Ok(SeededCourse {
414        course_id: course.id,
415        course_instance_id: instance.id,
416    })
417}
418
419/// Aligns the mock Suotar's world with the rows just written. Nothing is cleared first: the mock
420/// installs under a fresh generation and flips the pointer last.
421async fn push_mock_suotar_world(base_url: &str) -> Result<()> {
422    if !(bool_env_false_by_default("TEST_MODE")
423        && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT"))
424    {
425        info!("mock Suotar is not enabled; leaving its world alone");
426        return Ok(());
427    }
428    let url = SuotarConfiguration::mock_conf(base_url)?
429        .api_base_url
430        .join("control/command")?;
431    let mut payload = serde_json::to_value(mock_suotar_world())?;
432    if let Some(object) = payload.as_object_mut() {
433        object.insert("command".to_string(), serde_json::json!("pushWorld"));
434    }
435
436    info!("pushing the mock Suotar world");
437    let response = REQWEST_CLIENT
438        .post(url.clone())
439        .json(&payload)
440        .send()
441        .await?;
442    let status = response.status();
443    if !status.is_success() {
444        let body = response.text().await.unwrap_or_default();
445        // A worldless mock surfaces as baffling failures a hundred specs later.
446        anyhow::bail!("pushing the mock Suotar world to {url} failed with {status}: {body}");
447    }
448    Ok(())
449}
450
451/// Turns the module on with an enrolment link unique to its course code, without which the config
452/// check flags the module, and opts its linked students' completions in.
453fn credit_registration_config(course_code: &str) -> CreditRegistrationSeed {
454    CreditRegistrationSeed {
455        enrolment_link: Some(format!(
456            "https://www.avoin.helsinki.fi/palvelut/esittely.aspx?s=seed-{course_code}"
457        )),
458        paused_reason: None,
459        register_eligible_new_completions: true,
460    }
461}
462
463fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
464    CourseInstanceConfig {
465        name: None,
466        description: None,
467        support_email: None,
468        teacher_in_charge_name: "admin".to_string(),
469        teacher_in_charge_email: "admin@example.com".to_string(),
470        opening_time: None,
471        closing_time: None,
472        instance_id: Some(instance_id),
473    }
474}
475
476/// Owned by `suotar-old-flow-coexistence.spec.ts`.
477///
478/// Module 0 stays on the legacy pull path outright. Module 1 is opted into Suotar, but its one
479/// completion predates the opt-in and was already registered through the legacy pull path, so it
480/// must stay there and never get a Suotar registration. The two completions belong to different
481/// students because the spec tells them apart in the pull stream by address.
482async fn seed_old_flow_course(
483    conn: &mut PgConnection,
484    app_config: &ApplicationConfiguration,
485    org: Uuid,
486    teacher_user_id: Uuid,
487    students: &LinkedStudents,
488) -> Result<()> {
489    let cx = SeedContext {
490        teacher: teacher_user_id,
491        org,
492        base_course_ns: OLD_FLOW_COURSE_ID,
493    };
494    let registrar_id = get_or_create_default_registrar(conn).await?;
495    let still_legacy = students.get(&STUDENT_7)?;
496    let predates_opt_in = students.get(&STUDENT_8)?;
497
498    let (course, instance, _) =
499        CourseBuilder::new("Credit registration old flow", OLD_FLOW_COURSE_SLUG)
500            .desc("Fixture course left on the legacy open university registration flow.")
501            .course_id(OLD_FLOW_COURSE_ID)
502            .instance(instance_config(cx.v5(b"instance:old-flow")))
503            .module(
504                ModuleBuilder::new()
505                    .order(0)
506                    .ects(5.0)
507                    .uh_course_code(CRS_OLD_101.to_string())
508                    .register_to_open_university(true)
509                    .completion(
510                        CompletionBuilder::new(still_legacy.user_id)
511                            .email(still_legacy.email.clone())
512                            .grade(3)
513                            .passed(true)
514                            .prerequisite_modules_completed(true),
515                    ),
516            )
517            .module(
518                ModuleBuilder::new()
519                    .order(1)
520                    .name("Opted into Suotar")
521                    .ects(5.0)
522                    .uh_course_code(CRS_OLD_102.to_string())
523                    .credit_registration(CreditRegistrationSeed {
524                        register_eligible_new_completions: false,
525                        ..credit_registration_config(CRS_OLD_102)
526                    })
527                    .default_registrar(registrar_id)
528                    .completion(
529                        CompletionBuilder::new(predates_opt_in.user_id)
530                            .email(predates_opt_in.email.clone())
531                            .grade(3)
532                            .passed(true)
533                            .prerequisite_modules_completed(true)
534                            .registered(
535                                CompletionRegisteredBuilder::new()
536                                    .real_student_number(STUDENT_8.student_number),
537                            ),
538                    ),
539            )
540            .seed(conn, app_config, &cx)
541            .await?;
542
543    for student in [still_legacy, predates_opt_in] {
544        enroll(conn, student.user_id, course.id, instance.id).await?;
545    }
546    Ok(())
547}
548
549/// Owned by `completion-registration-certificate-detour.spec.ts`.
550///
551/// The one combination that draws the certificate detour on the old registration page: open
552/// university registration and a certificate the student can generate instead. No sample course has
553/// both, and the courses that come closest must keep the plain page they already test.
554///
555/// `failed_save_student` gets a completion of its own on the same module, so the spec's failed-save
556/// test can run independently of the test that saves a reason for `student` permanently.
557async fn seed_certificate_detour_course(
558    conn: &mut PgConnection,
559    app_config: &ApplicationConfiguration,
560    org: Uuid,
561    teacher_user_id: Uuid,
562    student: &SeededStudent,
563    failed_save_student: &SeededStudent,
564) -> Result<()> {
565    let cx = SeedContext {
566        teacher: teacher_user_id,
567        org,
568        base_course_ns: CERTIFICATE_DETOUR_COURSE_ID,
569    };
570
571    let (course, instance, module) = CourseBuilder::new(
572        "Credit registration certificate detour",
573        CERTIFICATE_DETOUR_COURSE_SLUG,
574    )
575    .desc("Fixture course whose open university module also offers a certificate.")
576    .course_id(CERTIFICATE_DETOUR_COURSE_ID)
577    .instance(instance_config(cx.v5(b"instance:certificate-detour")))
578    .module(
579        ModuleBuilder::new()
580            .order(0)
581            .ects(5.0)
582            .uh_course_code(CRS_DETOUR_101.to_string())
583            .register_to_open_university(true)
584            .completion(
585                CompletionBuilder::new(student.user_id)
586                    .email(student.email.clone())
587                    .grade(3)
588                    .passed(true)
589                    .prerequisite_modules_completed(true),
590            )
591            .completion(
592                CompletionBuilder::new(failed_save_student.user_id)
593                    .email(failed_save_student.email.clone())
594                    .grade(3)
595                    .passed(true)
596                    .prerequisite_modules_completed(true),
597            ),
598    )
599    .certificate_config("svgs/certificate-background.svg", None)
600    .seed(conn, app_config, &cx)
601    .await?;
602
603    course_modules::update_certification_enabled(conn, module.id, true).await?;
604    open_university_registration_links::upsert(conn, CRS_DETOUR_101, "https://www.example.com")
605        .await?;
606    for enrolled_student in [student, failed_save_student] {
607        enroll(conn, enrolled_student.user_id, course.id, instance.id).await?;
608    }
609    Ok(())
610}
611
612/// The account-linking fixtures, on a course of their own, and one linked student a tick can
613/// register.
614///
615/// The stale-address list only renders a (person, course) mailed to the cap and never claimed, which
616/// takes three mails at three addresses because the dedup key is the address.
617async fn seed_admin_course(
618    conn: &mut PgConnection,
619    app_config: &ApplicationConfiguration,
620    org: Uuid,
621    teacher_user_id: Uuid,
622    linked: &SeededStudent,
623) -> Result<()> {
624    let cx = SeedContext {
625        teacher: teacher_user_id,
626        org,
627        base_course_ns: ADMIN_COURSE_ID,
628    };
629    let (course, instance, module) =
630        CourseBuilder::new("Credit registration admin", ADMIN_COURSE_SLUG)
631            .desc("Fixture course for the admin dashboard's account linking views.")
632            .course_id(ADMIN_COURSE_ID)
633            .role(teacher_user_id, UserRole::Teacher)
634            .instance(instance_config(cx.v5(b"instance:admin")))
635            .module(
636                ModuleBuilder::new()
637                    .order(0)
638                    .ects(5.0)
639                    .uh_course_code(CRS_ADMIN_101.to_string())
640                    .credit_registration(credit_registration_config(CRS_ADMIN_101)),
641            )
642            .seed(conn, app_config, &cx)
643            .await?;
644
645    enroll(conn, linked.user_id, course.id, instance.id).await?;
646    seed_eligible_completion(conn, linked, module.id, course.id, None).await?;
647
648    for fixture in [&ADMIN_STALE, &TEACHER_RESEND_CAPPED] {
649        for suffix in MAILED_ADDRESS_SUFFIXES {
650            let address = format!("{suffix}{}", fixture.sisu_email);
651            let claimed = credit_registration_account_linking_emails::claim_send_slot(
652                conn,
653                &NewAccountLinkingEmail {
654                    student_number: DbSecret::new(fixture.student_number),
655                    sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
656                    course_id: course.id,
657                    emailed_to: DbSecret::new(address.clone()),
658                    student_number_verification_token_id: None,
659                    email_delivery_id: None,
660                },
661            )
662            .await?;
663            anyhow::ensure!(
664                claimed.is_some(),
665                "the dedup key refused a seeded linking mail to {address}"
666            );
667        }
668    }
669    Ok(())
670}
671
672/// Every registration state, and every error code, as a frozen row.
673///
674/// An account holds one completion per module, so the rows are spread over a grid of holders and
675/// modules. The modules are paused because every phase's claim query skips paused modules;
676/// otherwise the workers in the test deployment would walk these onwards seconds after the seed
677/// finished.
678async fn seed_states_course(
679    conn: &mut PgConnection,
680    app_config: &ApplicationConfiguration,
681    org: Uuid,
682    teacher_user_id: Uuid,
683    students: &LinkedStudents,
684) -> Result<()> {
685    let cx = SeedContext {
686        teacher: teacher_user_id,
687        org,
688        base_course_ns: STATES_COURSE_ID,
689    };
690    let mut course = CourseBuilder::new("Credit registration states", STATES_COURSE_SLUG)
691        .desc("Fixture course holding one frozen registration per state and per error code.")
692        .course_id(STATES_COURSE_ID)
693        .role(teacher_user_id, UserRole::Teacher)
694        .instance(instance_config(cx.v5(b"instance:states")));
695    for (order, course_code) in STATES_COURSE_CODES.iter().enumerate() {
696        let mut module = ModuleBuilder::new()
697            .order(order as i32)
698            .ects(5.0)
699            .uh_course_code(course_code.to_string())
700            .credit_registration(CreditRegistrationSeed {
701                paused_reason: Some(
702                    "Seeded fixture: these rows are read by the teacher and admin views and must not move."
703                        .to_string(),
704                ),
705                ..credit_registration_config(course_code)
706            });
707        if order > 0 {
708            module = module.name(format!("Module {course_code}"));
709        }
710        course = course.module(module);
711    }
712    let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
713    let seeded = SeededCourse {
714        course_id: course.id,
715        course_instance_id: instance.id,
716    };
717
718    let holders = [
719        students.get(&STUDENT_7)?,
720        students.get(&STUDENT_8)?,
721        students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
722        students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
723        students.get(&CREDIT_REGISTRATION_STUDENT_3)?,
724        students.get(&CREDIT_REGISTRATION_STUDENT_4)?,
725        students.get(&CREDIT_REGISTRATION_STUDENT_5)?,
726    ];
727    for holder in holders {
728        enroll(conn, holder.user_id, course.id, instance.id).await?;
729    }
730    let module_ids = module_ids_by_course_code(conn, course.id).await?;
731
732    // Every code on the same state, so the explorer's error-code filter can be exercised alone.
733    let rows = CreditRegistrationState::ALL
734        .iter()
735        .map(|state| (*state, None))
736        .chain(
737            CreditRegistrationErrorCode::ALL
738                .iter()
739                .map(|code| (CreditRegistrationState::FailedPermanent, Some(*code))),
740        );
741    for (index, (state, error_code)) in rows.enumerate() {
742        let holder = holders[index % holders.len()];
743        let course_code = STATES_COURSE_CODES
744            .get(index / holders.len())
745            .ok_or_else(|| anyhow::anyhow!("the states grid has too few modules"))?;
746        let module_id = *module_ids
747            .get(*course_code)
748            .ok_or_else(|| anyhow::anyhow!("no states module has code {course_code}"))?;
749        seed_frozen_registration(conn, &cx, seeded, module_id, holder, state, error_code).await?;
750    }
751    Ok(())
752}
753
754async fn module_ids_by_course_code(
755    conn: &mut PgConnection,
756    course_id: Uuid,
757) -> Result<HashMap<String, Uuid>> {
758    Ok(course_modules::get_by_course_id(conn, course_id)
759        .await?
760        .into_iter()
761        .filter_map(|module| module.uh_course_code.map(|code| (code, module.id)))
762        .collect())
763}
764
765/// Rows a teacher may put back on the queue and rows they may not, one per holder so a spec finds
766/// each by the holder's last name.
767///
768/// Its own course rather than more rows on the states course, because a bulk retry sweeps a whole
769/// course and would leave the states fixture with no `failed_permanent` row and no error codes.
770/// Paused for the same reason the states course is: a retried row has to hold still in
771/// `ready_to_submit` long enough for the spec to read it.
772async fn seed_retry_course(
773    conn: &mut PgConnection,
774    app_config: &ApplicationConfiguration,
775    org: Uuid,
776    teacher_user_id: Uuid,
777    students: &LinkedStudents,
778) -> Result<()> {
779    let cx = SeedContext {
780        teacher: teacher_user_id,
781        org,
782        base_course_ns: RETRY_COURSE_ID,
783    };
784    let (course, instance, module) =
785        CourseBuilder::new("Credit registration retry", RETRY_COURSE_SLUG)
786            .desc("Fixture course holding the registrations a teacher retries, and the ones they cannot.")
787            .course_id(RETRY_COURSE_ID)
788            .role(teacher_user_id, UserRole::Teacher)
789            .instance(instance_config(cx.v5(b"instance:retry")))
790            .module(
791                ModuleBuilder::new()
792                    .order(0)
793                    .ects(5.0)
794                    .uh_course_code(CRS_RETRY_101.to_string())
795                    .credit_registration(CreditRegistrationSeed {
796                        paused_reason: Some(
797                            "Seeded fixture: the retry specs read these rows and the workers must not move them."
798                                .to_string(),
799                        ),
800                        ..credit_registration_config(CRS_RETRY_101)
801                    }),
802            )
803            .seed(conn, app_config, &cx)
804            .await?;
805    let seeded = SeededCourse {
806        course_id: course.id,
807        course_instance_id: instance.id,
808    };
809
810    // The cancelled row is not a failure, so no retry of any shape moves it:
811    // `suotar-teacher-views.spec.ts` reads it both as the refusal and as the row whose state it
812    // asserts is unchanged.
813    for (fixture, state) in [
814        (
815            &CREDIT_REGISTRATION_STUDENT_1,
816            CreditRegistrationState::FailedPermanent,
817        ),
818        (
819            &CREDIT_REGISTRATION_STUDENT_2,
820            CreditRegistrationState::FailedPermanent,
821        ),
822        (
823            &CREDIT_REGISTRATION_STUDENT_3,
824            CreditRegistrationState::SubmissionUncertain,
825        ),
826        (
827            &CREDIT_REGISTRATION_STUDENT_4,
828            CreditRegistrationState::Cancelled,
829        ),
830    ] {
831        let holder = students.get(fixture)?;
832        enroll(conn, holder.user_id, course.id, instance.id).await?;
833        seed_frozen_registration(conn, &cx, seeded, module.id, holder, state, None).await?;
834    }
835    Ok(())
836}
837
838/// One completion and one ledger row parked in `state`, for `student` on `course_module_id`.
839async fn seed_frozen_registration(
840    conn: &mut PgConnection,
841    cx: &SeedContext,
842    course: SeededCourse,
843    course_module_id: Uuid,
844    student: &SeededStudent,
845    state: CreditRegistrationState,
846    error_code: Option<CreditRegistrationErrorCode>,
847) -> Result<()> {
848    let completion_id =
849        seed_eligible_completion(conn, student, course_module_id, course.course_id, None).await?;
850    let id = credit_registrations::insert(
851        conn,
852        PKeyPolicy::Fixed(
853            cx.v5(format!("credit-registration:{}:{course_module_id}", student.email).as_bytes()),
854        ),
855        &NewCreditRegistration {
856            course_module_completion_id: completion_id,
857            user_id: student.user_id,
858            course_id: course.course_id,
859            course_module_id,
860            course_instance_id: course.course_instance_id,
861            attempt_number: 1,
862        },
863        Some("Seeded fixture"),
864    )
865    .await?;
866    credit_registrations::transition(
867        conn,
868        id,
869        &Transition {
870            error_code,
871            needs_admin_attention: error_code.map(|_| credit_registrations::AdminAttention::Raise),
872            ..Transition::planted(state)
873        },
874    )
875    .await?;
876    Ok(())
877}
878
879/// One module per failing import shape, so the spec picks its error by picking a module rather than
880/// by flipping something every other spec on the course can see.
881async fn seed_import_outcomes_course(
882    conn: &mut PgConnection,
883    app_config: &ApplicationConfiguration,
884    org: Uuid,
885    teacher_user_id: Uuid,
886    student: &SeededStudent,
887) -> Result<()> {
888    let cx = SeedContext {
889        teacher: teacher_user_id,
890        org,
891        base_course_ns: IMPORT_OUTCOMES_COURSE_ID,
892    };
893    let mut course = CourseBuilder::new(
894        "Credit registration import outcomes",
895        IMPORT_OUTCOMES_COURSE_SLUG,
896    )
897    .desc("Fixture course whose modules each provoke one Sisu import error.")
898    .course_id(IMPORT_OUTCOMES_COURSE_ID)
899    .instance(instance_config(cx.v5(b"instance:import-outcomes")));
900    for (order, course_code) in IMPORT_OUTCOME_COURSE_CODES.iter().enumerate() {
901        let mut module = ModuleBuilder::new()
902            .order(order as i32)
903            .ects(5.0)
904            .uh_course_code(course_code.to_string())
905            .credit_registration(credit_registration_config(course_code));
906        if order > 0 {
907            module = module.name(format!("Module {course_code}"));
908        }
909        course = course.module(module);
910    }
911    let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
912    enroll(conn, student.user_id, course.id, instance.id).await?;
913    for module in course_modules::get_by_course_id(conn, course.id).await? {
914        seed_eligible_completion(conn, student, module.id, course.id, None).await?;
915    }
916    Ok(())
917}
918
919async fn seed_grade_improvement_course(
920    conn: &mut PgConnection,
921    app_config: &ApplicationConfiguration,
922    org: Uuid,
923    teacher_user_id: Uuid,
924    student: &SeededStudent,
925) -> Result<()> {
926    let cx = SeedContext {
927        teacher: teacher_user_id,
928        org,
929        base_course_ns: GRADE_IMPROVEMENT_COURSE_ID,
930    };
931    let (course, instance, module) = CourseBuilder::new(
932        "Credit registration grade improvement",
933        GRADE_IMPROVEMENT_COURSE_SLUG,
934    )
935    .desc("Fixture course whose module is graded rather than pass/fail.")
936    .course_id(GRADE_IMPROVEMENT_COURSE_ID)
937    .instance(instance_config(cx.v5(b"instance:grade-improvement")))
938    .module(
939        ModuleBuilder::new()
940            .order(0)
941            .ects(5.0)
942            .uh_course_code(CRS_GRADED_101.to_string())
943            .credit_registration(credit_registration_config(CRS_GRADED_101)),
944    )
945    .seed(conn, app_config, &cx)
946    .await?;
947    enroll(conn, student.user_id, course.id, instance.id).await?;
948    seed_eligible_completion(conn, student, module.id, course.id, Some(3)).await?;
949    Ok(())
950}
951
952async fn link_student_number(
953    conn: &mut PgConnection,
954    cx: &SeedContext,
955    fixture: &MockPersonFixture,
956    user_id: Uuid,
957    verified_via: StudentNumberVerificationMethod,
958) -> Result<()> {
959    let is_admin_manual = verified_via == StudentNumberVerificationMethod::AdminManual;
960    verified_student_numbers::insert(
961        conn,
962        PKeyPolicy::Fixed(cx.v5(format!("verified:{}", fixture.student_number).as_bytes())),
963        &NewVerifiedStudentNumber {
964            user_id,
965            student_number: DbSecret::new(fixture.student_number),
966            sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
967            first_names: Some(DbSecret::new(fixture.first_names)),
968            last_name: Some(DbSecret::new(fixture.last_name)),
969            verified_via,
970            verified_via_email: (!is_admin_manual).then(|| DbSecret::new(fixture.sisu_email)),
971            linked_by_user_id: is_admin_manual.then_some(cx.teacher),
972            link_reason: is_admin_manual
973                .then(|| "Seeded fixture: the address Sisu holds rejects our mail.".to_string()),
974            verified_from_course_id: None,
975        },
976    )
977    .await?;
978    Ok(())
979}
980
981/// Enrols the way a student who has been through the course's opening dialogs is: without
982/// `user_course_settings` and the AI notice acknowledgement, the course pages open on a dialog.
983async fn enroll(
984    conn: &mut PgConnection,
985    user_id: Uuid,
986    course_id: Uuid,
987    course_instance_id: Uuid,
988) -> Result<()> {
989    course_instance_enrollments::insert_enrollment_and_set_as_current(
990        conn,
991        NewCourseInstanceEnrollment {
992            user_id,
993            course_id,
994            course_instance_id,
995        },
996    )
997    .await?;
998    user_ai_usage_notice_acknowledgements::acknowledge(conn, user_id, course_id).await?;
999    Ok(())
1000}
1001
1002async fn default_module_id(conn: &mut PgConnection, course_id: Uuid) -> Result<Uuid> {
1003    Ok(course_modules::get_default_by_course_id(conn, course_id)
1004        .await?
1005        .id)
1006}
1007
1008/// A completion the pipeline will pick up. `prerequisite_modules_completed` is the trap: the builder
1009/// defaults it to false, and such a completion never leaves `pending`.
1010async fn seed_eligible_completion(
1011    conn: &mut PgConnection,
1012    student: &SeededStudent,
1013    course_module_id: Uuid,
1014    course_id: Uuid,
1015    grade: Option<i32>,
1016) -> Result<Uuid> {
1017    let completion_id = course_module_completions::insert_seed_row(
1018        conn,
1019        &NewCourseModuleCompletionSeed {
1020            course_id,
1021            course_module_id,
1022            user_id: student.user_id,
1023            completion_date: Some(Utc::now() - Duration::days(1)),
1024            completion_language: Some("en-US".to_string()),
1025            eligible_for_ects: Some(true),
1026            email: Some(student.email.clone()),
1027            grade,
1028            passed: Some(true),
1029            prerequisite_modules_completed: Some(true),
1030            needs_to_be_reviewed: Some(false),
1031        },
1032    )
1033    .await?;
1034    Ok(completion_id)
1035}
1036
1037async fn insert_student(
1038    conn: &mut PgConnection,
1039    user_id: Uuid,
1040    email: &str,
1041    first_name: &str,
1042    last_name: &str,
1043) -> Result<SeededStudent> {
1044    let user_id = users::insert(
1045        conn,
1046        PKeyPolicy::Fixed(user_id),
1047        email,
1048        Some(first_name),
1049        Some(last_name),
1050    )
1051    .await?;
1052    user_details::update_user_country(conn, user_id, "fi").await?;
1053    // The local part of the address is the password, so these students can log in through the
1054    // stored-password fallback without an entry in `authenticate_test_user`.
1055    let password = email
1056        .split('@')
1057        .next()
1058        .expect("split always yields one element");
1059    let hash = hash_password(&SecretString::new(password.to_string().into()))
1060        .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
1061    upsert_user_password(conn, user_id, &hash).await?;
1062    Ok(SeededStudent {
1063        user_id,
1064        email: email.to_string(),
1065    })
1066}
1067
1068/// `emailed_to` matches no seeded account on purpose: tokens are unbound, and bind to whoever opens
1069/// the link while logged in.
1070async fn seed_linking_tokens(
1071    conn: &mut PgConnection,
1072    cx: &SeedContext,
1073    course_id: Uuid,
1074    claimed_by_user_id: Uuid,
1075) -> Result<()> {
1076    let now = Utc::now();
1077    student_number_verification_tokens::insert_seed_row(
1078        conn,
1079        PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
1080        &SeedStudentNumberVerificationToken {
1081            token: LINKING_TOKEN_VALID.to_string(),
1082            student_number: LINK_VALID.student_number.to_string(),
1083            sisu_person_id: LINK_VALID.sisu_person_id(),
1084            first_names: Some(LINK_VALID.first_names.to_string()),
1085            last_name: Some(LINK_VALID.last_name.to_string()),
1086            emailed_to: LINK_VALID.sisu_email.to_string(),
1087            course_id: Some(course_id),
1088            expires_at: now + Duration::days(14),
1089            used_at: None,
1090            claimed_by_user_id: None,
1091        },
1092    )
1093    .await?;
1094    student_number_verification_tokens::insert_seed_row(
1095        conn,
1096        PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
1097        &SeedStudentNumberVerificationToken {
1098            token: LINKING_TOKEN_EXPIRED.to_string(),
1099            student_number: LINK_EXPIRED.student_number.to_string(),
1100            sisu_person_id: LINK_EXPIRED.sisu_person_id(),
1101            first_names: Some(LINK_EXPIRED.first_names.to_string()),
1102            last_name: Some(LINK_EXPIRED.last_name.to_string()),
1103            emailed_to: LINK_EXPIRED.sisu_email.to_string(),
1104            course_id: Some(course_id),
1105            expires_at: now - Duration::days(1),
1106            used_at: None,
1107            claimed_by_user_id: None,
1108        },
1109    )
1110    .await?;
1111    student_number_verification_tokens::insert_seed_row(
1112        conn,
1113        PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
1114        &SeedStudentNumberVerificationToken {
1115            token: LINKING_TOKEN_ALREADY_USED.to_string(),
1116            student_number: LINK_USED.student_number.to_string(),
1117            sisu_person_id: LINK_USED.sisu_person_id(),
1118            first_names: Some(LINK_USED.first_names.to_string()),
1119            last_name: Some(LINK_USED.last_name.to_string()),
1120            emailed_to: LINK_USED.sisu_email.to_string(),
1121            course_id: Some(course_id),
1122            expires_at: now + Duration::days(14),
1123            used_at: Some(now - Duration::hours(1)),
1124            claimed_by_user_id: Some(claimed_by_user_id),
1125        },
1126    )
1127    .await?;
1128    student_number_verification_tokens::insert_seed_row(
1129        conn,
1130        PKeyPolicy::Fixed(cx.v5(b"linking-token:conflict")),
1131        &SeedStudentNumberVerificationToken {
1132            token: LINKING_TOKEN_CONFLICT.to_string(),
1133            student_number: STUDENT_6.student_number.to_string(),
1134            sisu_person_id: STUDENT_6.sisu_person_id(),
1135            first_names: Some(STUDENT_6.first_names.to_string()),
1136            last_name: Some(STUDENT_6.last_name.to_string()),
1137            emailed_to: STUDENT_6.sisu_email.to_string(),
1138            course_id: Some(course_id),
1139            expires_at: now + Duration::days(14),
1140            used_at: None,
1141            claimed_by_user_id: None,
1142        },
1143    )
1144    .await?;
1145    Ok(())
1146}
1147
1148/// A registered grade-3 attempt superseded by a grade-4 one, so the admin-detail and profile specs
1149/// get an attempt chain without driving a regrade first.
1150async fn seed_superseded_attempt_pair(
1151    conn: &mut PgConnection,
1152    student: &SeededStudent,
1153    course: SeededCourse,
1154) -> Result<()> {
1155    let course_module_id = default_module_id(conn, course.course_id).await?;
1156    let completion_id = course_module_completions::insert_seed_row(
1157        conn,
1158        &NewCourseModuleCompletionSeed {
1159            course_id: course.course_id,
1160            course_module_id,
1161            user_id: student.user_id,
1162            completion_date: Some(Utc::now() - Duration::days(20)),
1163            completion_language: Some("en-US".to_string()),
1164            eligible_for_ects: Some(true),
1165            email: Some(student.email.clone()),
1166            grade: Some(4),
1167            passed: Some(true),
1168            prerequisite_modules_completed: Some(true),
1169            needs_to_be_reviewed: Some(false),
1170        },
1171    )
1172    .await?;
1173
1174    // One transaction: the deferred foreign key lets attempt 1 point at its successor before that
1175    // row exists, which is what clears `uq_credit_registrations_completion` for the insert.
1176    let mut tx = conn.begin().await?;
1177    let attempt_1 = insert_registered_attempt(
1178        &mut tx,
1179        SUPERSEDED_ATTEMPT_1_ID,
1180        completion_id,
1181        student.user_id,
1182        course,
1183        course_module_id,
1184        1,
1185        "3",
1186    )
1187    .await?;
1188    credit_registrations::mark_superseded(&mut tx, attempt_1, SUPERSEDED_ATTEMPT_2_ID).await?;
1189    insert_registered_attempt(
1190        &mut tx,
1191        SUPERSEDED_ATTEMPT_2_ID,
1192        completion_id,
1193        student.user_id,
1194        course,
1195        course_module_id,
1196        2,
1197        "4",
1198    )
1199    .await?;
1200    tx.commit().await?;
1201    Ok(())
1202}
1203
1204#[allow(clippy::too_many_arguments)]
1205async fn insert_registered_attempt(
1206    conn: &mut PgConnection,
1207    id: Uuid,
1208    course_module_completion_id: Uuid,
1209    user_id: Uuid,
1210    course: SeededCourse,
1211    course_module_id: Uuid,
1212    attempt_number: i32,
1213    grade_id: &str,
1214) -> Result<Uuid> {
1215    let id = credit_registrations::insert(
1216        conn,
1217        PKeyPolicy::Fixed(id),
1218        &NewCreditRegistration {
1219            course_module_completion_id,
1220            user_id,
1221            course_id: course.course_id,
1222            course_module_id,
1223            course_instance_id: course.course_instance_id,
1224            attempt_number,
1225        },
1226        Some("Seeded fixture"),
1227    )
1228    .await?;
1229    credit_registrations::set_payload_snapshot(
1230        conn,
1231        id,
1232        &PayloadSnapshot {
1233            student_number: DbSecret::new(STUDENT_6.student_number),
1234            sisu_person_id: Some(DbSecret::new(STUDENT_6.sisu_person_id())),
1235            uh_course_code: CRS_101.to_string(),
1236            selected_enrolment_id: Some(format!("otm-{}-degree", STUDENT_6.student_number)),
1237            selected_enrolment_kind: Some("degree".to_string()),
1238            selected_enrolment_realisation_id: Some("hy-opt-cur-090009011".to_string()),
1239            selected_enrolment_realisation_name: Some(serde_json::json!({
1240                "fi": "Rekisteröinnin testitoteutus",
1241                "en": "Registration test realisation",
1242                "sv": null,
1243            })),
1244            attained_at: Utc::now() - Duration::days(20),
1245            attainment_language: "en".to_string(),
1246            grade_scale_id: "sis-0-5".to_string(),
1247            grade_id: grade_id.to_string(),
1248            credits: 5.0,
1249        },
1250    )
1251    .await?;
1252    credit_registrations::transition(
1253        conn,
1254        id,
1255        &Transition::planted(CreditRegistrationState::Registered),
1256    )
1257    .await?;
1258    Ok(id)
1259}
1260
1261/// One `global_admin` and one `course_teacher` row, so the Audit tab has content without depending
1262/// on another spec having clicked something.
1263async fn seed_admin_actions(
1264    conn: &mut PgConnection,
1265    cx: &SeedContext,
1266    course_id: Uuid,
1267    teacher_user_id: Uuid,
1268) -> Result<()> {
1269    let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
1270        .await?
1271        .id;
1272    credit_registration_admin_actions::record(
1273        conn,
1274        &NewCreditRegistrationAdminAction {
1275            target_id: Some(SUPERSEDED_ATTEMPT_1_ID),
1276            reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
1277            before_state: Some(CreditRegistrationState::SubmissionUncertain),
1278            after_state: Some(CreditRegistrationState::Registered),
1279            affected_row_count: Some(1),
1280            ..NewCreditRegistrationAdminAction::new(
1281                CreditRegistrationAdminAction::TransitionItem,
1282                CreditRegistrationAdminActionTarget::CreditRegistration,
1283                admin_user_id,
1284                GLOBAL_ADMIN_ROLE,
1285            )
1286        },
1287    )
1288    .await?;
1289    credit_registration_admin_actions::record(
1290        conn,
1291        &NewCreditRegistrationAdminAction {
1292            target_id: Some(cx.v5(b"linking-token:valid")),
1293            actor_course_id: Some(course_id),
1294            reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
1295            affected_row_count: Some(1),
1296            ..NewCreditRegistrationAdminAction::new(
1297                CreditRegistrationAdminAction::ResendLinkEmail,
1298                CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
1299                teacher_user_id,
1300                COURSE_TEACHER_ROLE,
1301            )
1302        },
1303    )
1304    .await?;
1305    Ok(())
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311
1312    /// `student_number_verification_token_length` requires at least 128 characters; a violation
1313    /// would otherwise surface only as a seed crash.
1314    #[test]
1315    fn seeded_linking_tokens_are_long_enough() {
1316        for token in [
1317            LINKING_TOKEN_VALID,
1318            LINKING_TOKEN_EXPIRED,
1319            LINKING_TOKEN_ALREADY_USED,
1320            LINKING_TOKEN_CONFLICT,
1321        ] {
1322            assert!(token.len() >= 128, "token too short: {}", token.len());
1323        }
1324    }
1325}