1use 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
28pub const SUOTAR_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0001_4a5e_9e6e_c0de00000001);
30pub const SUOTAR_B_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0003_4a5e_9e6e_c0de00000003);
32pub const OLD_FLOW_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0002_4a5e_9e6e_c0de00000002);
34pub const IMPORT_OUTCOMES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0004_4a5e_9e6e_c0de00000004);
36pub const GRADE_IMPROVEMENT_COURSE_ID: Uuid =
38 Uuid::from_u128(0xc5ed17ea_0005_4a5e_9e6e_c0de00000005);
39pub const ADMIN_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0006_4a5e_9e6e_c0de00000006);
42pub const STATES_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0007_4a5e_9e6e_c0de00000007);
45pub 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";
51pub const CRS_OLD_102: &str = "CRS-OLD-102";
54pub const CRS_ADMIN_101: &str = "CRS-ADMIN-101";
55pub 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";
67pub 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
88pub struct MockPersonFixture {
91 pub student_number: &'static str,
92 pub first_names: &'static str,
93 pub last_name: &'static str,
94 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
105pub 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
130pub 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};
138pub 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
176pub 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 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 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 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 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 LaneCompletion {
250 student: &CREDIT_REGISTRATION_STUDENT_5,
251 course_code: CRS_B_101,
252 enrolments: NOT_ENROLLED,
253 },
254 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 LaneCompletion {
272 student: &CREDIT_REGISTRATION_STUDENT_4,
273 course_code: CRS_B_101,
274 enrolments: NOT_ENROLLED,
275 },
276 LaneCompletion {
278 student: &CREDIT_REGISTRATION_STUDENT_6,
279 course_code: CRS_B_101,
280 enrolments: NOT_ENROLLED,
281 },
282];
283
284pub 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};
293pub 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
303pub const MAILED_ADDRESS_SUFFIXES: [&str; 3] = ["", "old.", "older."];
306
307pub 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};
331pub 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 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 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 CourseUnitShape {
417 is_open_ended: true,
418 ..CourseUnitShape::new(CRS_IMPORT_103, IMPORT_OUTCOMES_COURSE_SLUG, 5.0)
419 },
420 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 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 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
528struct 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 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 #[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}