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