Skip to main content

headless_lms_server/controllers/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//! Student numbers are `90000SSPP`: `SS` the spec index listed below, `PP` the person within that
7//! spec, the leading `9` keeping them clear of real UH numbers. Digits only, 6–12 of them, per
8//! `verified_student_numbers.student_number_format`.
9//!
10//! Account linking and worker ticks are both global — one spec's tick advances every eligible row in
11//! the shared database — so two specs sharing a student number would see each other's registration
12//! attempts. Each spec under `system-tests/src/tests/credit-registration/` owns one `SS`, and the
13//! mock Suotar's persons must reuse the same numbers: 02 `suotar-account-linking`,
14//! 03 `suotar-enrolment-problems`, 04 `suotar-import-outcomes`, 05 `suotar-verify-outcomes`,
15//! 06 `suotar-sisu-outage`, 08 `suotar-teacher-views`, 09 `suotar-admin-dashboard`,
16//! 10 `suotar-old-flow-coexistence`, 11 `suotar-backfill`, 12 `suotar-grade-improvement`,
17//! 13 `suotar-student-emails`, 14 `suotar-fast-track-linking`, 15 `suotar-in-course-banner`,
18//! 16 `suotar-student-profile`. `01` belongs to no single spec: it holds the linked and unlinked
19//! students that read-only specs share. `07` is unused.
20//!
21//! Names and emails are unlikely strings (`Zzyzx …`) because a spec asserts their absence from the
22//! scrubbed Suotar API log.
23
24use chrono::{DateTime, Duration, Utc};
25use uuid::Uuid;
26
27use super::commands::{
28    CourseUnitUpsert, EnrolmentUpsert, PersonUpsert, ProductAccessTokenUpsert, RealisationUpsert,
29    WorldPush,
30};
31use super::ids as mock_ids;
32use super::world::{
33    CourseBehaviour, CreditRange, DatePeriod, EnrolmentState, LocalizedName, PersonBehaviour,
34    RealisationKind, Ripeness, WorldDefaults,
35};
36
37/// The course the Suotar specs live on.
38pub const SUOTAR_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0001_4a5e_9e6e_c0de00000001);
39/// A course left on the legacy open-university pull flow, for the coexistence specs.
40pub const OLD_FLOW_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0002_4a5e_9e6e_c0de00000002);
41/// Owned by `suotar-import-outcomes.spec.ts`; its modules are the failing shapes.
42pub const IMPORT_OUTCOMES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0004_4a5e_9e6e_c0de00000004);
43/// Owned by `suotar-grade-improvement.spec.ts`, and the only graded module here.
44pub const GRADE_IMPROVEMENT_COURSE_ID: Uuid =
45    Uuid::from_u128(0xc5ed17ea_0005_4a5e_9e6e_c0de00000005);
46/// Owned outright by `suotar-backfill.spec.ts`, which flips the Suotar flag on.
47pub const BACKFILL_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0003_4a5e_9e6e_c0de00000003);
48/// Owned exclusively by `suotar-admin-dashboard.spec.ts`: discovery and the linking mails tick by
49/// course, so the spec that ticks them needs a course no other spec has students on.
50pub const ADMIN_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0006_4a5e_9e6e_c0de00000006);
51/// One frozen ledger row per registration state and per error code, on a paused module. Read by
52/// `suotar-teacher-views.spec.ts` and the admin explorer, written by neither.
53pub const STATES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0007_4a5e_9e6e_c0de00000007);
54/// Owned by `suotar-teacher-views.spec.ts`'s retry half, and swept by its bulk retry.
55pub const RETRY_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0009_4a5e_9e6e_c0de00000009);
56
57pub const CRS_101: &str = "CRS-101";
58pub const CRS_102: &str = "CRS-102";
59pub const CRS_OLD_101: &str = "CRS-OLD-101";
60/// The module `suotar-old-flow-coexistence.spec.ts` treats as already cut over: Suotar-enabled, but
61/// holding a completion the legacy pull path registered before the cutover happened.
62pub const CRS_OLD_102: &str = "CRS-OLD-102";
63pub const CRS_BACKFILL_101: &str = "CRS-BACKFILL-101";
64pub const CRS_ADMIN_101: &str = "CRS-ADMIN-101";
65pub const CRS_STATES_101: &str = "CRS-STATES-101";
66pub const CRS_RETRY_101: &str = "CRS-RETRY-101";
67
68pub const CRS_GRADED_101: &str = "CRS-GRADED-101";
69/// One module per import failure, each breaking exactly one thing.
70pub const CRS_IMPORT_101: &str = "CRS-IMPORT-101";
71pub const CRS_IMPORT_102: &str = "CRS-IMPORT-102";
72pub const CRS_IMPORT_103: &str = "CRS-IMPORT-103";
73pub const CRS_IMPORT_104: &str = "CRS-IMPORT-104";
74pub const IMPORT_OUTCOME_COURSE_CODES: [&str; 4] = [
75    CRS_IMPORT_101,
76    CRS_IMPORT_102,
77    CRS_IMPORT_103,
78    CRS_IMPORT_104,
79];
80
81pub const SUOTAR_COURSE_SLUG: &str = "credit-registration-via-suotar";
82pub const OLD_FLOW_COURSE_SLUG: &str = "credit-registration-old-flow";
83pub const BACKFILL_COURSE_SLUG: &str = "credit-registration-backfill";
84pub const IMPORT_OUTCOMES_COURSE_SLUG: &str = "credit-registration-import-outcomes";
85pub const GRADE_IMPROVEMENT_COURSE_SLUG: &str = "credit-registration-grade-improvement";
86pub const ADMIN_COURSE_SLUG: &str = "credit-registration-admin";
87pub const STATES_COURSE_SLUG: &str = "credit-registration-states";
88pub const RETRY_COURSE_SLUG: &str = "credit-registration-retry";
89
90/// A seeded student and the Sisu person the mock must answer with for them. The database rows and
91/// the pushed persons must carry the same identifiers, so both are built from here.
92pub struct MockPersonFixture {
93    pub student_number: &'static str,
94    pub first_names: &'static str,
95    pub last_name: &'static str,
96    /// Where the account-linking mail goes.
97    pub sisu_email: &'static str,
98    pub account_email: Option<&'static str>,
99}
100
101impl MockPersonFixture {
102    pub fn sisu_person_id(&self) -> String {
103        format!("hy-hlo-{}", self.student_number)
104    }
105}
106
107/// Linked to its student number from seed time: `suotar-student-profile.spec.ts` reads its linked
108/// card.
109pub const LINKED_STUDENT: MockPersonFixture = MockPersonFixture {
110    student_number: "900000101",
111    first_names: "Zzyzx",
112    last_name: "Numberlinked",
113    sisu_email: "zzyzx.numberlinked@helsinki.example",
114    account_email: Some("credit-registration-linked-student@example.com"),
115};
116/// The twin with no student number linked, enrolled and nothing more.
117pub const UNLINKED_STUDENT: MockPersonFixture = MockPersonFixture {
118    student_number: "900000102",
119    first_names: "Zzyzx",
120    last_name: "Linkpending",
121    sisu_email: "zzyzx.linkpending@helsinki.example",
122    account_email: Some("credit-registration-unlinked-student@example.com"),
123};
124/// The import that times out. Its own person, so the fault keyed on this student number cannot reach
125/// another spec's row on the shared course.
126///
127/// Has a seeded person but, unlike its `on_crs_101` neighbours, no seeded enrolment: a pre-seeded one
128/// would let some other spec's unscoped tick resolve and import the row for real before this one ever
129/// arms the `sisuTimeout` fault. Its own spec creates the enrolment atomically with the fault. Keeping
130/// the person seeded matters — without it, that same unscoped tick answers `personNotFound`, which
131/// drops `verified_student_number` and strands the row in `pending` for good.
132pub const IMPORT_TIMEOUT: MockPersonFixture = MockPersonFixture {
133    student_number: "900000402",
134    first_names: "Zzyzx",
135    last_name: "Timedout",
136    sisu_email: "zzyzx.timedout@helsinki.example",
137    account_email: Some("credit-registration-import-timeout@example.com"),
138};
139/// The outage spec's own person, so a fault keyed on this student number cannot reach another
140/// spec's row on the shared course. Enrolment left unseeded for `IMPORT_TIMEOUT`'s reason: its spec
141/// arms the outage before creating the enrolment, so no earlier unscoped sweep can import the row
142/// while the study registry is still answering normally.
143pub const SISU_OUTAGE: MockPersonFixture = MockPersonFixture {
144    student_number: "900000601",
145    first_names: "Zzyzx",
146    last_name: "Outaged",
147    sisu_email: "zzyzx.outaged@helsinki.example",
148    account_email: Some("credit-registration-sisu-outage@example.com"),
149};
150/// Deliberately absent from the mock's enrolments: the only way to reach `no_usable_enrolment`
151/// without arming a fault.
152pub const NO_ENROLMENT: MockPersonFixture = MockPersonFixture {
153    student_number: "900000301",
154    first_names: "Zzyzx",
155    // Not "Notenrolled": its trigram distance from "notexisting" is close enough to spuriously
156    // match search-users.spec.ts's no-such-user search.
157    last_name: "Unenrolled",
158    sisu_email: "zzyzx.unenrolled@helsinki.example",
159    account_email: Some("credit-registration-no-enrolment@example.com"),
160};
161/// Enrolled on one course twice, through a degree programme and through the open university, so the
162/// selection policy has something to choose between.
163pub const TWO_ENROLMENTS: MockPersonFixture = MockPersonFixture {
164    student_number: "900000302",
165    first_names: "Zzyzx",
166    last_name: "Twicenrolled",
167    sisu_email: "zzyzx.twicenrolled@helsinki.example",
168    account_email: Some("credit-registration-two-enrolments@example.com"),
169};
170pub const VERIFY_POLLING: MockPersonFixture = MockPersonFixture {
171    student_number: "900000501",
172    first_names: "Zzyzx",
173    last_name: "Polling",
174    sisu_email: "zzyzx.polling@helsinki.example",
175    account_email: Some("credit-registration-verify-polling@example.com"),
176};
177pub const VERIFY_MISREGISTERED: MockPersonFixture = MockPersonFixture {
178    student_number: "900000502",
179    first_names: "Zzyzx",
180    last_name: "Reversed",
181    sisu_email: "zzyzx.reversed@helsinki.example",
182    account_email: Some("credit-registration-verify-misregistered@example.com"),
183};
184pub const ADMIN_UNLINKED: MockPersonFixture = MockPersonFixture {
185    student_number: "900000902",
186    first_names: "Zzyzx",
187    last_name: "Unlinked",
188    sisu_email: "zzyzx.unlinked@helsinki.example",
189    account_email: Some("credit-registration-admin-unlinked@example.com"),
190};
191/// Mailed to the cap, never claimed, no account: the stale-address population is the only place the
192/// resend and manual-link actions render.
193pub const ADMIN_STALE: MockPersonFixture = MockPersonFixture {
194    student_number: "900000903",
195    first_names: "Zzyzx",
196    last_name: "Deadaddress",
197    sisu_email: "zzyzx.deadaddress@helsinki.example",
198    account_email: None,
199};
200/// A second capped person on `ADMIN_STALE`'s course, owned by the teacher specs so a cap-refused
201/// resend cannot race whichever spec is overriding the cap.
202pub const TEACHER_RESEND_CAPPED: MockPersonFixture = MockPersonFixture {
203    student_number: "900000804",
204    first_names: "Zzyzx",
205    last_name: "Cappedmail",
206    sisu_email: "zzyzx.cappedmail@helsinki.example",
207    account_email: None,
208};
209
210pub const ADMIN_LINKED: MockPersonFixture = MockPersonFixture {
211    student_number: "900000904",
212    first_names: "Zzyzx",
213    last_name: "Alreadylinked",
214    sisu_email: "zzyzx.alreadylinked@helsinki.example",
215    account_email: Some("credit-registration-admin-linked@example.com"),
216};
217
218/// Three distinct addresses out of one Sisu address: the dedup key is the address and the cap is
219/// three mails per person and course.
220pub const MAILED_ADDRESS_SUFFIXES: [&str; 3] = ["", "old.", "older."];
221
222/// Enrolled on a Suotar course and nothing else, for the profile tab's empty state.
223pub const PROFILE_EMPTY_EMAIL: &str = "credit-registration-profile-empty@example.com";
224
225/// Claims the seeded linking tokens, and holds no student number of its own: the tokens are unbound
226/// and bind to whoever opens the link.
227pub const LINK_CLAIMER_EMAIL: &str = "credit-registration-link-claimer@example.com";
228pub const LINK_VALID: MockPersonFixture = MockPersonFixture {
229    student_number: "900000201",
230    first_names: "Zzyzx",
231    last_name: "Linkvalid",
232    sisu_email: "zzyzx.linkvalid@helsinki.example",
233    account_email: None,
234};
235pub const LINK_EXPIRED: MockPersonFixture = MockPersonFixture {
236    student_number: "900000202",
237    first_names: "Zzyzx",
238    last_name: "Linkexpired",
239    sisu_email: "zzyzx.linkexpired@helsinki.example",
240    account_email: None,
241};
242pub const LINK_USED: MockPersonFixture = MockPersonFixture {
243    student_number: "900000203",
244    first_names: "Zzyzx",
245    last_name: "Linkused",
246    sisu_email: "zzyzx.linkused@helsinki.example",
247    account_email: None,
248};
249pub const SUPERSEDED: MockPersonFixture = MockPersonFixture {
250    student_number: "900000901",
251    first_names: "Zzyzx",
252    last_name: "Regraded",
253    sisu_email: "zzyzx.regraded@helsinki.example",
254    account_email: Some("credit-registration-superseded@example.com"),
255};
256/// Its Sisu address equals the account address, which is what the fast track fires on; the twin
257/// below differs only in the account's verification flag.
258pub const FAST_TRACK_VERIFIED: MockPersonFixture = MockPersonFixture {
259    student_number: "900001401",
260    first_names: "Zzyzx",
261    last_name: "Fasttrack",
262    sisu_email: "credit-registration-verified-email@example.com",
263    account_email: Some("credit-registration-verified-email@example.com"),
264};
265pub const FAST_TRACK_TWIN: MockPersonFixture = MockPersonFixture {
266    student_number: "900001402",
267    first_names: "Zzyzx",
268    last_name: "Nearmiss",
269    sisu_email: "credit-registration-unverified-twin@example.com",
270    account_email: Some("credit-registration-unverified-twin@example.com"),
271};
272/// Verified far outside the recency window: a deprovisioned university address can be reissued, and
273/// the account holding it would still look verified.
274pub const FAST_TRACK_STALE: MockPersonFixture = MockPersonFixture {
275    student_number: "900001403",
276    first_names: "Zzyzx",
277    last_name: "Staleproof",
278    sisu_email: "credit-registration-stale-proof@example.com",
279    account_email: Some("credit-registration-stale-proof@example.com"),
280};
281/// The recycled-address signal: the address is proved, but the account belongs to somebody else.
282/// `seed_fast_track_near_misses` gives its account a name unlike the registry's.
283pub const FAST_TRACK_NAME_MISMATCH: MockPersonFixture = MockPersonFixture {
284    student_number: "900001404",
285    first_names: "Zzyzx",
286    last_name: "Registryname",
287    sisu_email: "credit-registration-name-mismatch@example.com",
288    account_email: Some("credit-registration-name-mismatch@example.com"),
289};
290/// Its account already holds [`FAST_TRACK_OTHER_NUMBER`], so swapping it belongs behind the mailed
291/// link's confirmation screen, which names both numbers.
292pub const FAST_TRACK_HAS_NUMBER: MockPersonFixture = MockPersonFixture {
293    student_number: "900001405",
294    first_names: "Zzyzx",
295    last_name: "Alreadynumbered",
296    sisu_email: "credit-registration-has-number@example.com",
297    account_email: Some("credit-registration-has-number@example.com"),
298};
299/// Not on any roster: only the account's existing link needs it to exist.
300pub const FAST_TRACK_OTHER_NUMBER: &str = "900001495";
301/// `mock_suotar_world` moves its account address to the registry's *secondary* field, which is
302/// self-entered there and therefore never proof.
303pub const FAST_TRACK_SECONDARY_ONLY: MockPersonFixture = MockPersonFixture {
304    student_number: "900001406",
305    first_names: "Zzyzx",
306    last_name: "Secondonly",
307    sisu_email: "zzyzx.secondonly@helsinki.example",
308    account_email: Some("credit-registration-secondary-only@example.com"),
309};
310/// A confirmed account whose address the registry simply does not hold. The population the linking
311/// mail exists for, and the regression that matters most: it must still be mailed.
312pub const FAST_TRACK_NO_MATCH: MockPersonFixture = MockPersonFixture {
313    student_number: "900001407",
314    first_names: "Zzyzx",
315    last_name: "Othermailbox",
316    sisu_email: "zzyzx.othermailbox@helsinki.example",
317    account_email: Some("credit-registration-fast-track-no-match@example.com"),
318};
319
320/// Absent from the mock's enrolments, like `NO_ENROLMENT`, so its row parks where the in-course
321/// re-enrol banner shows. Its spec only reads and dismisses, so the row stays parked.
322pub const BANNER_STUCK: MockPersonFixture = MockPersonFixture {
323    student_number: "900001501",
324    first_names: "Zzyzx",
325    last_name: "Bannerstuck",
326    sisu_email: "zzyzx.bannerstuck@helsinki.example",
327    account_email: Some("credit-registration-banner-stuck@example.com"),
328};
329/// Its own person, because its spec creates the enrolment that makes the banner go away and that
330/// must not clear another spec's banner.
331pub const BANNER_REENROLS: MockPersonFixture = MockPersonFixture {
332    student_number: "900001502",
333    first_names: "Zzyzx",
334    last_name: "Bannerreenrols",
335    sisu_email: "zzyzx.bannerreenrols@helsinki.example",
336    account_email: Some("credit-registration-banner-reenrols@example.com"),
337};
338
339/// Driven all the way to `registered`, which is what earns the "your credits are in Sisu" mail.
340pub const EMAILS_REGISTERED: MockPersonFixture = MockPersonFixture {
341    student_number: "900001301",
342    first_names: "Zzyzx",
343    last_name: "Mailedsuccess",
344    sisu_email: "zzyzx.mailedsuccess@helsinki.example",
345    account_email: Some("credit-registration-emails-registered@example.com"),
346};
347/// Deliberately absent from the mock's enrolments, like `NO_ENROLMENT`, so its row parks at
348/// `no_usable_enrolment` and earns the action-needed mail without arming a fault.
349pub const EMAILS_NO_ENROLMENT: MockPersonFixture = MockPersonFixture {
350    student_number: "900001302",
351    first_names: "Zzyzx",
352    last_name: "Mailedaction",
353    sisu_email: "zzyzx.mailedaction@helsinki.example",
354    account_email: Some("credit-registration-emails-no-enrolment@example.com"),
355};
356
357pub const IMPORT_OUTCOMES: MockPersonFixture = MockPersonFixture {
358    student_number: "900000401",
359    first_names: "Zzyzx",
360    last_name: "Importoutcomes",
361    sisu_email: "zzyzx.importoutcomes@helsinki.example",
362    account_email: Some("credit-registration-import-outcomes@example.com"),
363};
364pub const GRADE_IMPROVEMENT: MockPersonFixture = MockPersonFixture {
365    student_number: "900001201",
366    first_names: "Zzyzx",
367    last_name: "Improved",
368    sisu_email: "zzyzx.improved@helsinki.example",
369    account_email: Some("credit-registration-grade-improvement@example.com"),
370};
371
372pub const BACKFILL_STUDENTS: [MockPersonFixture; 4] = [
373    MockPersonFixture {
374        student_number: "900001101",
375        first_names: "Zzyzx",
376        last_name: "Backfill1",
377        sisu_email: "zzyzx.backfill1@helsinki.example",
378        account_email: Some("credit-registration-backfill-1@example.com"),
379    },
380    MockPersonFixture {
381        student_number: "900001102",
382        first_names: "Zzyzx",
383        last_name: "Backfill2",
384        sisu_email: "zzyzx.backfill2@helsinki.example",
385        account_email: Some("credit-registration-backfill-2@example.com"),
386    },
387    MockPersonFixture {
388        student_number: "900001103",
389        first_names: "Zzyzx",
390        last_name: "Backfill3",
391        sisu_email: "zzyzx.backfill3@helsinki.example",
392        account_email: Some("credit-registration-backfill-3@example.com"),
393    },
394    MockPersonFixture {
395        student_number: "900001104",
396        first_names: "Zzyzx",
397        last_name: "Backfill4",
398        sisu_email: "zzyzx.backfill4@helsinki.example",
399        account_email: Some("credit-registration-backfill-4@example.com"),
400    },
401];
402
403/// The world the mock Suotar serves, built from the same fixtures the database rows above are.
404///
405/// Pure and pool-free on purpose: the restore-from-template setup path runs no seed and has the mock
406/// install this lazily instead, so both setup scripts hand the suite the same fixtures.
407pub fn mock_suotar_world() -> WorldPush {
408    let now = Utc::now();
409    let wide = DatePeriod {
410        start_date: (now - Duration::days(730)).date_naive(),
411        end_date: (now + Duration::days(730)).date_naive(),
412    };
413    let past = DatePeriod {
414        start_date: (now - Duration::days(900)).date_naive(),
415        end_date: (now - Duration::days(800)).date_naive(),
416    };
417
418    let on_crs_101 = [
419        &LINKED_STUDENT,
420        &UNLINKED_STUDENT,
421        &LINK_VALID,
422        &LINK_EXPIRED,
423        &LINK_USED,
424        &SUPERSEDED,
425        &FAST_TRACK_VERIFIED,
426        &FAST_TRACK_TWIN,
427        &FAST_TRACK_STALE,
428        &FAST_TRACK_NAME_MISMATCH,
429        &FAST_TRACK_HAS_NUMBER,
430        &FAST_TRACK_SECONDARY_ONLY,
431        &FAST_TRACK_NO_MATCH,
432        &IMPORT_TIMEOUT,
433        &SISU_OUTAGE,
434        &TWO_ENROLMENTS,
435        &VERIFY_POLLING,
436        &VERIFY_MISREGISTERED,
437        &EMAILS_REGISTERED,
438    ];
439    let on_crs_admin_101 = [
440        &ADMIN_UNLINKED,
441        &ADMIN_STALE,
442        &ADMIN_LINKED,
443        &TEACHER_RESEND_CAPPED,
444    ];
445
446    let mut persons: Vec<PersonUpsert> = on_crs_101.iter().map(|f| person(f)).collect();
447    persons.extend(on_crs_admin_101.iter().map(|f| {
448        let mut upsert = person(f);
449        // Sisu's live address must differ from the mailed history below, or a resend hits the
450        // dedup guard (already mailed this address) before it ever reaches the cap it exists to
451        // demonstrate.
452        if f.student_number == ADMIN_STALE.student_number
453            || f.student_number == TEACHER_RESEND_CAPPED.student_number
454        {
455            upsert.primary_email = format!("current.{}", f.sisu_email);
456        }
457        upsert
458    }));
459    for upsert in &mut persons {
460        // The one fixture whose account address is on the registry's secondary field. The mail still
461        // goes to both addresses; only the fast track's proof is primary-only.
462        if upsert.student_number == FAST_TRACK_SECONDARY_ONLY.student_number {
463            upsert.secondary_email = FAST_TRACK_SECONDARY_ONLY.account_email.map(str::to_string);
464        }
465    }
466    persons.push(person(&NO_ENROLMENT));
467    persons.push(person(&EMAILS_NO_ENROLMENT));
468    persons.push(person(&BANNER_STUCK));
469    persons.push(person(&BANNER_REENROLS));
470    persons.push(person(&IMPORT_OUTCOMES));
471    persons.push(person(&GRADE_IMPROVEMENT));
472    persons.extend(BACKFILL_STUDENTS.iter().map(person));
473
474    let mut enrolments: Vec<EnrolmentUpsert> = on_crs_101
475        .iter()
476        // Like `NO_ENROLMENT`, minus the person: these two specs create their own enrolment, so no
477        // earlier, unscoped resolve-enrolments sweep can resolve it before their fault is armed.
478        .filter(|fixture| {
479            fixture.student_number != IMPORT_TIMEOUT.student_number
480                && fixture.student_number != SISU_OUTAGE.student_number
481        })
482        .map(|fixture| enrolment(fixture, CRS_101, RealisationKind::Degree, wide.clone(), now))
483        .collect();
484    enrolments.extend(BACKFILL_STUDENTS.iter().map(|fixture| {
485        enrolment(
486            fixture,
487            CRS_BACKFILL_101,
488            RealisationKind::Degree,
489            wide.clone(),
490            now,
491        )
492    }));
493    enrolments.extend(IMPORT_OUTCOME_COURSE_CODES.iter().map(|course_code| {
494        EnrolmentUpsert {
495            // The plain (student, kind) id would collide across all four: one student enrolled in
496            // four realisations of the same kind at once.
497            id: Some(mock_ids::enrolment_id_for_course(
498                IMPORT_OUTCOMES.student_number,
499                course_code,
500                RealisationKind::Degree,
501            )),
502            ..enrolment(
503                &IMPORT_OUTCOMES,
504                course_code,
505                RealisationKind::Degree,
506                wide.clone(),
507                now,
508            )
509        }
510    }));
511    enrolments.push(enrolment(
512        &GRADE_IMPROVEMENT,
513        CRS_GRADED_101,
514        RealisationKind::Degree,
515        wide.clone(),
516        now,
517    ));
518    enrolments.extend(on_crs_admin_101.iter().map(|fixture| {
519        enrolment(
520            fixture,
521            CRS_ADMIN_101,
522            RealisationKind::Degree,
523            wide.clone(),
524            now,
525        )
526    }));
527    enrolments.push(enrolment(
528        &TWO_ENROLMENTS,
529        CRS_101,
530        RealisationKind::OpenUniversity,
531        wide.clone(),
532        now,
533    ));
534
535    let import_outcomes = [
536        CourseUnitShape {
537            import_allowed: false,
538            ..CourseUnitShape::new(CRS_IMPORT_101, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
539        },
540        CourseUnitShape {
541            credits: Some(CreditRange { min: 1.0, max: 1.0 }),
542            ..CourseUnitShape::new(CRS_IMPORT_102, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
543        },
544        CourseUnitShape {
545            activity_period: Some(past),
546            ..CourseUnitShape::new(CRS_IMPORT_103, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
547        },
548        CourseUnitShape {
549            acceptor: false,
550            ..CourseUnitShape::new(CRS_IMPORT_104, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
551        },
552    ];
553
554    let mut course_units = vec![
555        CourseUnitShape {
556            kinds: &[RealisationKind::Degree, RealisationKind::OpenUniversity],
557            ..CourseUnitShape::new(CRS_101, SUOTAR_COURSE_SLUG, 5.0)
558        }
559        .build(&wide),
560        CourseUnitShape::new(CRS_102, SUOTAR_COURSE_SLUG, 3.0).build(&wide),
561        CourseUnitShape {
562            kinds: &[RealisationKind::OpenUniversity],
563            ..CourseUnitShape::new(CRS_OLD_101, OLD_FLOW_COURSE_SLUG, 5.0)
564        }
565        .build(&wide),
566        CourseUnitShape::new(CRS_BACKFILL_101, BACKFILL_COURSE_SLUG, 5.0).build(&wide),
567        CourseUnitShape {
568            kinds: &[RealisationKind::Degree, RealisationKind::OpenUniversity],
569            ..CourseUnitShape::new(CRS_ADMIN_101, ADMIN_COURSE_SLUG, 5.0)
570        }
571        .build(&wide),
572        CourseUnitShape::new(CRS_STATES_101, STATES_COURSE_SLUG, 5.0).build(&wide),
573        CourseUnitShape::new(CRS_RETRY_101, RETRY_COURSE_SLUG, 5.0).build(&wide),
574        // Every other module is pass/fail, so grade improvement needs a graded one.
575        CourseUnitShape {
576            grade_scale_id: "sis-0-5",
577            ..CourseUnitShape::new(CRS_GRADED_101, GRADE_IMPROVEMENT_COURSE_SLUG, 5.0)
578        }
579        .build(&wide),
580    ];
581    course_units.extend(import_outcomes.into_iter().map(|shape| shape.build(&wide)));
582
583    WorldPush {
584        defaults: Some(WorldDefaults {
585            ripeness: Ripeness::Manual,
586            ..WorldDefaults::default()
587        }),
588        persons,
589        course_units,
590        enrolments,
591        attainments: Vec::new(),
592        submissions: Vec::new(),
593        product_tokens: vec![
594            product_token(CRS_101),
595            product_token(CRS_OLD_101),
596            product_token(CRS_ADMIN_101),
597        ],
598    }
599}
600
601fn person(fixture: &MockPersonFixture) -> PersonUpsert {
602    PersonUpsert {
603        student_number: fixture.student_number.to_string(),
604        person_id: Some(fixture.sisu_person_id()),
605        first_names: fixture.first_names.to_string(),
606        last_name: fixture.last_name.to_string(),
607        primary_email: fixture.sisu_email.to_string(),
608        secondary_email: None,
609        behaviour: PersonBehaviour::default(),
610        owner_user_email: fixture.account_email.map(str::to_string),
611    }
612}
613
614fn enrolment(
615    fixture: &MockPersonFixture,
616    course_code: &str,
617    kind: RealisationKind,
618    validity: DatePeriod,
619    enrolled_at: DateTime<Utc>,
620) -> EnrolmentUpsert {
621    EnrolmentUpsert {
622        id: None,
623        student_number: fixture.student_number.to_string(),
624        course_code: course_code.to_string(),
625        realisation_id: None,
626        kind,
627        state: EnrolmentState::Enrolled,
628        study_right_id: None,
629        study_right_validity_period: validity,
630        enrolment_date_time: Some(enrolled_at),
631    }
632}
633
634/// What the mock's realisation for one module looks like. The defaults are the working shape; the
635/// import-outcomes modules each break exactly one of them.
636struct CourseUnitShape<'a> {
637    course_code: &'a str,
638    course_slug: &'a str,
639    ects: f64,
640    kinds: &'a [RealisationKind],
641    grade_scale_id: &'a str,
642    credits: Option<CreditRange>,
643    activity_period: Option<DatePeriod>,
644    acceptor: bool,
645    import_allowed: bool,
646}
647
648impl<'a> CourseUnitShape<'a> {
649    fn new(course_code: &'a str, course_slug: &'a str, ects: f64) -> Self {
650        Self {
651            course_code,
652            course_slug,
653            ects,
654            kinds: &[RealisationKind::Degree],
655            grade_scale_id: "sis-hyl-hyv",
656            credits: None,
657            activity_period: None,
658            acceptor: true,
659            import_allowed: true,
660        }
661    }
662
663    fn build(self, activity_period: &DatePeriod) -> CourseUnitUpsert {
664        let name = LocalizedName {
665            fi: self.course_code.to_string(),
666            sv: self.course_code.to_string(),
667            en: self.course_code.to_string(),
668        };
669        CourseUnitUpsert {
670            course_code: self.course_code.to_string(),
671            course_unit_id: None,
672            name: Some(name),
673            realisations: self
674                .kinds
675                .iter()
676                .map(|kind| RealisationUpsert {
677                    id: None,
678                    name: None,
679                    assessment_item_id: None,
680                    kind: *kind,
681                    activity_period: self
682                        .activity_period
683                        .clone()
684                        .unwrap_or_else(|| activity_period.clone()),
685                    grade_scale_id: self.grade_scale_id.to_string(),
686                    credits: self.credits.clone().unwrap_or(CreditRange {
687                        min: self.ects,
688                        max: self.ects,
689                    }),
690                    acceptor_person_id: self.acceptor.then(|| "hy-hlo-acceptor".to_string()),
691                    open_university_product_id: match kind {
692                        RealisationKind::OpenUniversity => Some(product_id(self.course_code)),
693                        RealisationKind::Degree => None,
694                    },
695                })
696                .collect(),
697            behaviour: CourseBehaviour {
698                import_allowed: self.import_allowed,
699            },
700            owner_course_slug: Some(self.course_slug.to_string()),
701        }
702    }
703}
704
705fn product_token(course_code: &str) -> ProductAccessTokenUpsert {
706    ProductAccessTokenUpsert {
707        open_university_product_id: product_id(course_code),
708        id: None,
709        access_token: None,
710        state: None,
711        document_state: None,
712    }
713}
714
715pub fn product_id(course_code: &str) -> String {
716    format!("otm-product-{}", course_code.to_lowercase())
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    /// A world that does not hang together surfaces as confusing Playwright failures elsewhere.
724    #[test]
725    fn the_pushed_world_is_internally_consistent() {
726        let world = mock_suotar_world();
727        let scales = world
728            .defaults
729            .as_ref()
730            .map(|defaults| defaults.grade_scales.clone())
731            .unwrap_or_default();
732        let product_ids: Vec<&String> = world
733            .product_tokens
734            .iter()
735            .map(|token| &token.open_university_product_id)
736            .collect();
737
738        for unit in &world.course_units {
739            for realisation in &unit.realisations {
740                assert!(
741                    scales
742                        .iter()
743                        .any(|scale| scale.answers_to(&realisation.grade_scale_id)),
744                    "{} names an unknown grade scale {}",
745                    unit.course_code,
746                    realisation.grade_scale_id
747                );
748                if let Some(product_id) = &realisation.open_university_product_id {
749                    assert!(
750                        product_ids.contains(&product_id),
751                        "{} references a product token that was not pushed: {product_id}",
752                        unit.course_code
753                    );
754                }
755            }
756        }
757
758        for enrolment in &world.enrolments {
759            assert!(
760                world
761                    .persons
762                    .iter()
763                    .any(|person| person.student_number == enrolment.student_number),
764                "enrolment for an unpushed person {}",
765                enrolment.student_number
766            );
767            let unit = world
768                .course_units
769                .iter()
770                .find(|unit| unit.course_code == enrolment.course_code)
771                .unwrap_or_else(|| {
772                    panic!("enrolment on an unpushed course {}", enrolment.course_code)
773                });
774            assert!(
775                unit.realisations
776                    .iter()
777                    .any(|realisation| realisation.kind == enrolment.kind),
778                "{} has no {:?} realisation for {} to enrol on",
779                unit.course_code,
780                enrolment.kind,
781                enrolment.student_number
782            );
783        }
784    }
785}