Skip to main content

headless_lms_server/mock_suotar/
fixtures.rs

1//! The fixture identities the credit-registration (Suotar) system tests are built from.
2//!
3//! The seed writes the database rows and the mock study registry serves the matching registry rows,
4//! so neither side owns these: both read them here.
5//!
6//! Every seeded account holds one student number for good, so a number is shared by every spec that
7//! drives that account. Isolation is per (student number, course code) instead: the mock and the
8//! workers are global, so no two specs may drive the same account on the same course.
9//! [`LANE_COMPLETIONS`] and the table in `system-tests/src/utils/creditRegistration.ts` allocate
10//! them. Numbers are digits only, 6–12 of them, per `verified_student_numbers.student_number_format`,
11//! the leading `9` keeping them clear of real UH numbers.
12//!
13//! Names and emails are unlikely strings (`Zzyzx …`) because a spec asserts their absence from the
14//! scrubbed Suotar API log.
15
16use chrono::{DateTime, Duration, Utc};
17use uuid::Uuid;
18
19use super::commands::{
20    AttainmentUpsert, CourseUnitUpsert, EnrolmentUpsert, PersonUpsert, RealisationUpsert,
21    SisuViolationsUpsert, WorldPush,
22};
23use super::world::{
24    CourseBehaviour, CreditRange, DatePeriod, EnrolmentState, LocalizedName, PersonBehaviour,
25    RealisationKind, SuotarCourse, WorldDefaults,
26};
27
28/// The general Suotar course, and the only one with a chapter page to read.
29pub const SUOTAR_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0001_4a5e_9e6e_c0de00000001);
30/// A second general Suotar course, so one account can serve a spec on each.
31pub const SUOTAR_B_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0003_4a5e_9e6e_c0de00000003);
32/// A course left on the legacy open-university pull flow, for the coexistence specs.
33pub const OLD_FLOW_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0002_4a5e_9e6e_c0de00000002);
34/// Owned by `suotar-import-outcomes.spec.ts`; its modules are the failing shapes.
35pub const IMPORT_OUTCOMES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0004_4a5e_9e6e_c0de00000004);
36/// Owned by `suotar-grade-improvement.spec.ts`, and the only graded module here.
37pub const GRADE_IMPROVEMENT_COURSE_ID: Uuid =
38    Uuid::from_u128(0xc5ed17ea_0005_4a5e_9e6e_c0de00000005);
39/// Owned by `suotar-admin-dashboard.spec.ts`: discovery and the linking mails tick by course, so the
40/// spec that ticks them needs a roster no other spec's unlinked people are on.
41pub const ADMIN_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0006_4a5e_9e6e_c0de00000006);
42/// One frozen ledger row per registration state and per error code, on paused modules. Read by
43/// `suotar-teacher-views.spec.ts` and the admin explorer, written by neither.
44pub const STATES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0007_4a5e_9e6e_c0de00000007);
45/// Owned by `suotar-teacher-views.spec.ts`'s retry half, and swept by its bulk retry.
46pub const RETRY_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0009_4a5e_9e6e_c0de00000009);
47
48pub const CRS_101: &str = "CRS-101";
49pub const CRS_B_101: &str = "CRS-B-101";
50pub const CRS_OLD_101: &str = "CRS-OLD-101";
51/// The module `suotar-old-flow-coexistence.spec.ts` treats as already cut over: Suotar-enabled, but
52/// holding a completion the legacy pull path registered before the cutover happened.
53pub const CRS_OLD_102: &str = "CRS-OLD-102";
54pub const CRS_ADMIN_101: &str = "CRS-ADMIN-101";
55/// One module per column of the frozen-row grid: an account holds one completion per module.
56pub const STATES_COURSE_CODES: [&str; 6] = [
57    "CRS-STATES-101",
58    "CRS-STATES-102",
59    "CRS-STATES-103",
60    "CRS-STATES-104",
61    "CRS-STATES-105",
62    "CRS-STATES-106",
63];
64pub const CRS_RETRY_101: &str = "CRS-RETRY-101";
65
66pub const CRS_GRADED_101: &str = "CRS-GRADED-101";
67/// One module per import failure, each breaking exactly one thing.
68pub const CRS_IMPORT_101: &str = "CRS-IMPORT-101";
69pub const CRS_IMPORT_102: &str = "CRS-IMPORT-102";
70pub const CRS_IMPORT_103: &str = "CRS-IMPORT-103";
71pub const CRS_IMPORT_104: &str = "CRS-IMPORT-104";
72pub const IMPORT_OUTCOME_COURSE_CODES: [&str; 4] = [
73    CRS_IMPORT_101,
74    CRS_IMPORT_102,
75    CRS_IMPORT_103,
76    CRS_IMPORT_104,
77];
78
79pub const SUOTAR_COURSE_SLUG: &str = "credit-registration-via-suotar";
80pub const SUOTAR_B_COURSE_SLUG: &str = "credit-registration-via-suotar-b";
81pub const OLD_FLOW_COURSE_SLUG: &str = "credit-registration-old-flow";
82pub const IMPORT_OUTCOMES_COURSE_SLUG: &str = "credit-registration-import-outcomes";
83pub const GRADE_IMPROVEMENT_COURSE_SLUG: &str = "credit-registration-grade-improvement";
84pub const ADMIN_COURSE_SLUG: &str = "credit-registration-admin";
85pub const STATES_COURSE_SLUG: &str = "credit-registration-states";
86pub const RETRY_COURSE_SLUG: &str = "credit-registration-retry";
87
88/// A Sisu person, and the seeded account it belongs to if any. The database rows and the pushed
89/// persons must carry the same identifiers, so both are built from here.
90pub struct MockPersonFixture {
91    pub student_number: &'static str,
92    pub first_names: &'static str,
93    pub last_name: &'static str,
94    /// Where the account-linking mail goes.
95    pub sisu_email: &'static str,
96    pub account_email: Option<&'static str>,
97}
98
99impl MockPersonFixture {
100    pub fn sisu_person_id(&self) -> String {
101        format!("hy-hlo-{}", self.student_number)
102    }
103}
104
105/// A general seeded student the credit-registration seed links to this number, like `student7` and
106/// `student8`. Kept off every row that asks something of the student:
107/// `suotar-student-profile.spec.ts` asserts its studies page is clean.
108pub const STUDENT_6: MockPersonFixture = MockPersonFixture {
109    student_number: "090000065",
110    first_names: "Zzyzx",
111    last_name: "Studentsix",
112    sisu_email: "zzyzx.studentsix@helsinki.example.com",
113    account_email: Some("student6@example.com"),
114};
115pub const STUDENT_7: MockPersonFixture = MockPersonFixture {
116    student_number: "090000078",
117    first_names: "Zzyzx",
118    last_name: "Studentseven",
119    sisu_email: "zzyzx.studentseven@helsinki.example.com",
120    account_email: Some("student7@example.com"),
121};
122pub const STUDENT_8: MockPersonFixture = MockPersonFixture {
123    student_number: "090000081",
124    first_names: "Zzyzx",
125    last_name: "Studenteight",
126    sisu_email: "zzyzx.studenteight@helsinki.example.com",
127    account_email: Some("student8@example.com"),
128};
129
130/// Created and linked to this number by the credit-registration seed, like the four below.
131pub const CREDIT_REGISTRATION_STUDENT_1: MockPersonFixture = MockPersonFixture {
132    student_number: "090000117",
133    first_names: "Zzyzx",
134    last_name: "Crsone",
135    sisu_email: "zzyzx.crsone@helsinki.example.com",
136    account_email: Some("credit-registration-student-1@example.com"),
137};
138/// Linked by support by hand rather than by the mailed link, which the teacher view renders
139/// differently.
140pub const CREDIT_REGISTRATION_STUDENT_2: MockPersonFixture = MockPersonFixture {
141    student_number: "090000120",
142    first_names: "Zzyzx",
143    last_name: "Crstwo",
144    sisu_email: "zzyzx.crstwo@helsinki.example.com",
145    account_email: Some("credit-registration-student-2@example.com"),
146};
147pub const CREDIT_REGISTRATION_STUDENT_3: MockPersonFixture = MockPersonFixture {
148    student_number: "090000133",
149    first_names: "Zzyzx",
150    last_name: "Crsthree",
151    sisu_email: "zzyzx.crsthree@helsinki.example.com",
152    account_email: Some("credit-registration-student-3@example.com"),
153};
154pub const CREDIT_REGISTRATION_STUDENT_4: MockPersonFixture = MockPersonFixture {
155    student_number: "090000146",
156    first_names: "Zzyzx",
157    last_name: "Crsfour",
158    sisu_email: "zzyzx.crsfour@helsinki.example.com",
159    account_email: Some("credit-registration-student-4@example.com"),
160};
161pub const CREDIT_REGISTRATION_STUDENT_5: MockPersonFixture = MockPersonFixture {
162    student_number: "090000159",
163    first_names: "Zzyzx",
164    last_name: "Crsfive",
165    sisu_email: "zzyzx.crsfive@helsinki.example.com",
166    account_email: Some("credit-registration-student-5@example.com"),
167};
168pub const CREDIT_REGISTRATION_STUDENT_6: MockPersonFixture = MockPersonFixture {
169    student_number: "090000162",
170    first_names: "Zzyzx",
171    last_name: "Crssix",
172    sisu_email: "zzyzx.crssix@helsinki.example.com",
173    account_email: Some("credit-registration-student-6@example.com"),
174};
175
176/// A seeded completion on one of the two general Suotar courses, and the enrolments the registry
177/// holds for it. Without one the row parks at `no_usable_enrolment`, or waits for its spec to create
178/// the enrolment after arming a fault, so no earlier unscoped sweep can import the row first.
179pub struct LaneCompletion {
180    pub student: &'static MockPersonFixture,
181    pub course_code: &'static str,
182    pub enrolments: &'static [RealisationKind],
183}
184
185const NOT_ENROLLED: &[RealisationKind] = &[];
186const DEGREE: &[RealisationKind] = &[RealisationKind::Degree];
187
188pub const LANE_COMPLETIONS: [LaneCompletion; 17] = [
189    // suotar-in-course-banner
190    LaneCompletion {
191        student: &STUDENT_7,
192        course_code: CRS_101,
193        enrolments: NOT_ENROLLED,
194    },
195    LaneCompletion {
196        student: &STUDENT_8,
197        course_code: CRS_101,
198        enrolments: NOT_ENROLLED,
199    },
200    // suotar-enrolment-problems
201    LaneCompletion {
202        student: &CREDIT_REGISTRATION_STUDENT_1,
203        course_code: CRS_101,
204        enrolments: &[RealisationKind::Degree, RealisationKind::OpenUniversity],
205    },
206    LaneCompletion {
207        student: &STUDENT_7,
208        course_code: CRS_B_101,
209        enrolments: NOT_ENROLLED,
210    },
211    // suotar-student-emails
212    LaneCompletion {
213        student: &CREDIT_REGISTRATION_STUDENT_2,
214        course_code: CRS_101,
215        enrolments: DEGREE,
216    },
217    LaneCompletion {
218        student: &STUDENT_8,
219        course_code: CRS_B_101,
220        enrolments: NOT_ENROLLED,
221    },
222    // suotar-import-outcomes
223    LaneCompletion {
224        student: &CREDIT_REGISTRATION_STUDENT_3,
225        course_code: CRS_101,
226        enrolments: NOT_ENROLLED,
227    },
228    LaneCompletion {
229        student: &CREDIT_REGISTRATION_STUDENT_3,
230        course_code: CRS_B_101,
231        enrolments: NOT_ENROLLED,
232    },
233    LaneCompletion {
234        student: &CREDIT_REGISTRATION_STUDENT_4,
235        course_code: CRS_101,
236        enrolments: NOT_ENROLLED,
237    },
238    LaneCompletion {
239        student: &CREDIT_REGISTRATION_STUDENT_5,
240        course_code: CRS_101,
241        enrolments: NOT_ENROLLED,
242    },
243    LaneCompletion {
244        student: &CREDIT_REGISTRATION_STUDENT_6,
245        course_code: CRS_101,
246        enrolments: NOT_ENROLLED,
247    },
248    // suotar-enrolment-problems; the registry already holds this credit
249    LaneCompletion {
250        student: &CREDIT_REGISTRATION_STUDENT_5,
251        course_code: CRS_B_101,
252        enrolments: NOT_ENROLLED,
253    },
254    // suotar-verify-outcomes
255    LaneCompletion {
256        student: &STUDENT_6,
257        course_code: CRS_B_101,
258        enrolments: DEGREE,
259    },
260    LaneCompletion {
261        student: &CREDIT_REGISTRATION_STUDENT_1,
262        course_code: CRS_B_101,
263        enrolments: DEGREE,
264    },
265    LaneCompletion {
266        student: &CREDIT_REGISTRATION_STUDENT_2,
267        course_code: CRS_B_101,
268        enrolments: DEGREE,
269    },
270    // suotar-sisu-outage
271    LaneCompletion {
272        student: &CREDIT_REGISTRATION_STUDENT_4,
273        course_code: CRS_B_101,
274        enrolments: NOT_ENROLLED,
275    },
276    // suotar-enrolment-recheck
277    LaneCompletion {
278        student: &CREDIT_REGISTRATION_STUDENT_6,
279        course_code: CRS_B_101,
280        enrolments: NOT_ENROLLED,
281    },
282];
283
284/// Mailed to the cap, never claimed, no account: the stale-address population is the only place the
285/// resend and manual-link actions render.
286pub const ADMIN_STALE: MockPersonFixture = MockPersonFixture {
287    student_number: "090009037",
288    first_names: "Zzyzx",
289    last_name: "Deadaddress",
290    sisu_email: "zzyzx.deadaddress@helsinki.example.com",
291    account_email: None,
292};
293/// A second capped person on `ADMIN_STALE`'s course, owned by the teacher specs so a cap-refused
294/// resend cannot race whichever spec is overriding the cap.
295pub const TEACHER_RESEND_CAPPED: MockPersonFixture = MockPersonFixture {
296    student_number: "090008041",
297    first_names: "Zzyzx",
298    last_name: "Cappedmail",
299    sisu_email: "zzyzx.cappedmail@helsinki.example.com",
300    account_email: None,
301};
302
303/// Three distinct addresses out of one Sisu address: the dedup key is the address and the cap is
304/// three mails per person and course.
305pub const MAILED_ADDRESS_SUFFIXES: [&str; 3] = ["", "old.", "older."];
306
307/// Claims the seeded linking tokens, and holds no student number of its own: the tokens are unbound
308/// and bind to whoever opens the link.
309pub const LINK_CLAIMER_EMAIL: &str = "credit-registration-link-claimer@example.com";
310pub const LINK_VALID: MockPersonFixture = MockPersonFixture {
311    student_number: "090002018",
312    first_names: "Zzyzx",
313    last_name: "Linkvalid",
314    sisu_email: "zzyzx.linkvalid@helsinki.example.com",
315    account_email: None,
316};
317pub const LINK_EXPIRED: MockPersonFixture = MockPersonFixture {
318    student_number: "090002021",
319    first_names: "Zzyzx",
320    last_name: "Linkexpired",
321    sisu_email: "zzyzx.linkexpired@helsinki.example.com",
322    account_email: None,
323};
324pub const LINK_USED: MockPersonFixture = MockPersonFixture {
325    student_number: "090002034",
326    first_names: "Zzyzx",
327    last_name: "Linkused",
328    sisu_email: "zzyzx.linkused@helsinki.example.com",
329    account_email: None,
330};
331/// The world the mock Suotar serves, built from the same fixtures the database rows are.
332///
333/// Pure and pool-free on purpose: the restore-from-template setup path runs no seed and has the mock
334/// install this lazily instead, so both setup scripts hand the suite the same fixtures.
335pub fn mock_suotar_world() -> WorldPush {
336    let now = Utc::now();
337    let wide = DatePeriod {
338        start_date: (now - Duration::days(730)).date_naive(),
339        end_date: Some((now + Duration::days(730)).date_naive()),
340    };
341    let degree = |fixture: &MockPersonFixture, course_code: &str| {
342        enrolment(
343            fixture,
344            course_code,
345            RealisationKind::Degree,
346            wide.clone(),
347            now,
348        )
349    };
350
351    let seeded_students = [
352        &STUDENT_6,
353        &STUDENT_7,
354        &STUDENT_8,
355        &CREDIT_REGISTRATION_STUDENT_1,
356        &CREDIT_REGISTRATION_STUDENT_2,
357        &CREDIT_REGISTRATION_STUDENT_3,
358        &CREDIT_REGISTRATION_STUDENT_4,
359        &CREDIT_REGISTRATION_STUDENT_5,
360        &CREDIT_REGISTRATION_STUDENT_6,
361    ];
362    let link_token_people = [&LINK_VALID, &LINK_EXPIRED, &LINK_USED];
363    let admin_roster = [
364        &CREDIT_REGISTRATION_STUDENT_1,
365        &ADMIN_STALE,
366        &TEACHER_RESEND_CAPPED,
367    ];
368
369    let mut persons: Vec<PersonUpsert> = seeded_students
370        .iter()
371        .chain(&link_token_people)
372        .map(|fixture| person(fixture))
373        .collect();
374    for fixture in [&ADMIN_STALE, &TEACHER_RESEND_CAPPED] {
375        persons.push(PersonUpsert {
376            // Sisu's live address must differ from the mailed history, or a resend hits the dedup
377            // guard (already mailed this address) before it ever reaches the cap it exists to
378            // demonstrate.
379            primary_email: Some(format!("current.{}", fixture.sisu_email)),
380            ..person(fixture)
381        });
382    }
383
384    let mut enrolments: Vec<EnrolmentUpsert> = LANE_COMPLETIONS
385        .iter()
386        .flat_map(|lane| {
387            lane.enrolments
388                .iter()
389                .map(|kind| enrolment(lane.student, lane.course_code, *kind, wide.clone(), now))
390        })
391        .collect();
392    // The replaced attempt pair `suotar-student-profile.spec.ts` reads.
393    enrolments.push(degree(&STUDENT_6, CRS_101));
394    enrolments.extend(link_token_people.iter().map(|f| degree(f, CRS_101)));
395    enrolments.extend(admin_roster.iter().map(|f| degree(f, CRS_ADMIN_101)));
396    enrolments.extend(
397        IMPORT_OUTCOME_COURSE_CODES
398            .iter()
399            .map(|course_code| degree(&STUDENT_8, course_code)),
400    );
401    enrolments.push(degree(&CREDIT_REGISTRATION_STUDENT_2, CRS_GRADED_101));
402
403    let import_outcomes = [
404        CourseUnitShape {
405            carried_by_suotar: false,
406            ..CourseUnitShape::new(CRS_IMPORT_101, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
407        },
408        CourseUnitShape {
409            credits: Some(CreditRange {
410                min: 5.0,
411                max: None,
412            }),
413            ..CourseUnitShape::new(CRS_IMPORT_102, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
414        },
415        // Sisu refuses the send; the realisation with no end date is a shape the client must read.
416        CourseUnitShape {
417            is_open_ended: true,
418            ..CourseUnitShape::new(CRS_IMPORT_103, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
419        },
420        // Graded on 0–5 in Sisu while the module registers pass/fail.
421        CourseUnitShape {
422            grade_scale_id: "sis-0-5",
423            ..CourseUnitShape::new(CRS_IMPORT_104, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
424        },
425    ];
426    let both_kinds = &[RealisationKind::Degree, RealisationKind::OpenUniversity];
427
428    let mut course_units = vec![
429        CourseUnitShape {
430            kinds: both_kinds,
431            ..CourseUnitShape::new(CRS_101, SUOTAR_COURSE_SLUG, 5.0)
432        }
433        .build(&wide),
434        CourseUnitShape {
435            kinds: both_kinds,
436            ..CourseUnitShape::new(CRS_B_101, SUOTAR_B_COURSE_SLUG, 5.0)
437        }
438        .build(&wide),
439        CourseUnitShape {
440            kinds: &[RealisationKind::OpenUniversity],
441            ..CourseUnitShape::new(CRS_OLD_101, OLD_FLOW_COURSE_SLUG, 5.0)
442        }
443        .build(&wide),
444        CourseUnitShape {
445            kinds: both_kinds,
446            ..CourseUnitShape::new(CRS_ADMIN_101, ADMIN_COURSE_SLUG, 5.0)
447        }
448        .build(&wide),
449        CourseUnitShape::new(CRS_RETRY_101, RETRY_COURSE_SLUG, 5.0).build(&wide),
450        // Every other module is pass/fail, so grade improvement needs a graded one.
451        CourseUnitShape {
452            grade_scale_id: "sis-0-5",
453            ..CourseUnitShape::new(CRS_GRADED_101, GRADE_IMPROVEMENT_COURSE_SLUG, 5.0)
454        }
455        .build(&wide),
456    ];
457    course_units.extend(STATES_COURSE_CODES.iter().map(|course_code| {
458        CourseUnitShape::new(course_code, STATES_COURSE_SLUG, 5.0).build(&wide)
459    }));
460    course_units.extend(import_outcomes.into_iter().map(|shape| shape.build(&wide)));
461
462    WorldPush {
463        defaults: Some(WorldDefaults::default()),
464        persons,
465        course_units,
466        enrolments,
467        // In the world from the start, so no check finds the student without it.
468        attainments: vec![AttainmentUpsert {
469            id: None,
470            student_number: CREDIT_REGISTRATION_STUDENT_5.student_number.to_string(),
471            course_code: CRS_B_101.to_string(),
472            person_id: None,
473            kind: RealisationKind::Degree,
474            attainment_type: None,
475            state: None,
476            attainment_date: (now - Duration::days(400)).date_naive(),
477            registration_date: None,
478            grade_scale_id: "sis-hyl-hyv".to_string(),
479            grade_id: "1".to_string(),
480            passed: Some(true),
481            credits: None,
482        }],
483        submissions: Vec::new(),
484        sisu_violations: vec![SisuViolationsUpsert {
485            student_number: STUDENT_8.student_number.to_string(),
486            course_code: CRS_IMPORT_103.to_string(),
487            violations: vec![
488                "Student must have an active study right on attainment date or on credit transfer date.".to_string(),
489            ],
490        }],
491    }
492}
493
494fn person(fixture: &MockPersonFixture) -> PersonUpsert {
495    PersonUpsert {
496        student_number: fixture.student_number.to_string(),
497        person_id: Some(fixture.sisu_person_id()),
498        first_names: Some(fixture.first_names.to_string()),
499        last_name: Some(fixture.last_name.to_string()),
500        primary_email: Some(fixture.sisu_email.to_string()),
501        secondary_email: None,
502        behaviour: PersonBehaviour::default(),
503        owner_user_email: fixture.account_email.map(str::to_string),
504    }
505}
506
507fn enrolment(
508    fixture: &MockPersonFixture,
509    course_code: &str,
510    kind: RealisationKind,
511    validity: DatePeriod,
512    enrolled_at: DateTime<Utc>,
513) -> EnrolmentUpsert {
514    EnrolmentUpsert {
515        id: None,
516        student_number: fixture.student_number.to_string(),
517        course_code: course_code.to_string(),
518        realisation_id: None,
519        kind,
520        state: EnrolmentState::Enrolled,
521        study_right_id: None,
522        study_right_validity_period: Some(validity),
523        study_right_grant_date: None,
524        enrolment_date_time: Some(Some(enrolled_at)),
525    }
526}
527
528/// What the mock's course unit for one module looks like. The defaults are the working shape; the
529/// import-outcomes modules each break exactly one of them.
530struct CourseUnitShape<'a> {
531    course_code: &'a str,
532    course_slug: &'a str,
533    ects: f64,
534    kinds: &'a [RealisationKind],
535    grade_scale_id: &'a str,
536    credits: Option<CreditRange>,
537    carried_by_suotar: bool,
538    /// The realisations' activity periods have no end date.
539    is_open_ended: bool,
540}
541
542impl<'a> CourseUnitShape<'a> {
543    fn new(course_code: &'a str, course_slug: &'a str, ects: f64) -> Self {
544        Self {
545            course_code,
546            course_slug,
547            ects,
548            kinds: &[RealisationKind::Degree],
549            grade_scale_id: "sis-hyl-hyv",
550            credits: None,
551            carried_by_suotar: true,
552            is_open_ended: false,
553        }
554    }
555
556    fn build(self, activity_period: &DatePeriod) -> CourseUnitUpsert {
557        let name = LocalizedName {
558            fi: self.course_code.to_string(),
559            sv: self.course_code.to_string(),
560            en: self.course_code.to_string(),
561        };
562        CourseUnitUpsert {
563            course_code: self.course_code.to_string(),
564            course_unit_id: None,
565            name: Some(name),
566            credits: Some(self.credits.unwrap_or(CreditRange {
567                min: self.ects,
568                max: Some(self.ects),
569            })),
570            grade_scale_id: Some(self.grade_scale_id.to_string()),
571            realisations: self
572                .kinds
573                .iter()
574                .map(|kind| RealisationUpsert {
575                    id: None,
576                    name: None,
577                    assessment_item_id: None,
578                    kind: *kind,
579                    activity_period: Some(DatePeriod {
580                        end_date: activity_period.end_date.filter(|_| !self.is_open_ended),
581                        ..activity_period.clone()
582                    }),
583                    grade_scale_id: None,
584                })
585                .collect(),
586            suotar_course: self.carried_by_suotar.then(|| SuotarCourse {
587                name: self.course_code.to_string(),
588            }),
589            behaviour: CourseBehaviour::default(),
590            owner_course_slug: Some(self.course_slug.to_string()),
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    /// A world that does not hang together surfaces as confusing Playwright failures elsewhere.
600    #[test]
601    fn the_pushed_world_is_internally_consistent() {
602        let world = mock_suotar_world();
603        let scales = world
604            .defaults
605            .as_ref()
606            .map(|defaults| defaults.grade_scales.clone())
607            .unwrap_or_default();
608
609        for unit in &world.course_units {
610            if let Some(scale_id) = &unit.grade_scale_id {
611                assert!(
612                    scales.iter().any(|scale| &scale.id == scale_id),
613                    "{} names an unknown grade scale {scale_id}",
614                    unit.course_code
615                );
616            }
617        }
618
619        for enrolment in &world.enrolments {
620            assert!(
621                world
622                    .persons
623                    .iter()
624                    .any(|person| person.student_number == enrolment.student_number),
625                "enrolment for an unpushed person {}",
626                enrolment.student_number
627            );
628            let unit = world
629                .course_units
630                .iter()
631                .find(|unit| unit.course_code == enrolment.course_code)
632                .unwrap_or_else(|| {
633                    panic!("enrolment on an unpushed course {}", enrolment.course_code)
634                });
635            assert!(
636                unit.realisations
637                    .iter()
638                    .any(|realisation| realisation.kind == enrolment.kind),
639                "{} has no {:?} realisation for {} to enrol on",
640                unit.course_code,
641                enrolment.kind,
642                enrolment.student_number
643            );
644        }
645    }
646}