1use std::collections::BTreeMap;
6
7use chrono::NaiveDate;
8use serde::Deserializer;
9use serde_json::json;
10use sqlx::PgPool;
11
12use crate::prelude::*;
13
14use super::default_world;
15use super::faults::{Fault, OwnerRef, Predicate, ResolvedOwner, Stage, validate};
16use super::ids;
17use super::scenarios;
18use super::store::{EntityHash, MockSuotarStore, OwnerKeys, World};
19use super::wire::Endpoint;
20use super::world::{
21 AttainmentLevel, AttainmentState, CourseBehaviour, CreditRange, DatePeriod, EnrolmentState,
22 GradeScale, ImporterVisibility, LocalizedName, MockAttainment, MockCourseUnit, MockEnrolment,
23 MockPerson, MockRealisation, MockStudyRight, MockSubmission, PENDING_WINDOW_HOURS,
24 PersonBehaviour, RealisationKind, RecordedCall, SendState, SuotarCourse, WorldDefaults,
25 person_course_key,
26};
27
28const DEFAULT_CALL_LIMIT: usize = 200;
29pub(crate) const WORLD_DUMP_CALL_LIMIT: usize = 100;
30
31#[derive(Debug, Deserialize)]
32#[serde(rename_all = "camelCase", tag = "command")]
33pub enum MockSuotarCommand {
34 Reset {
35 scope: ResetScope,
36 },
37 PushWorld(WorldPush),
38 UpsertPersons {
39 persons: Vec<PersonUpsert>,
40 },
41 #[serde(rename_all = "camelCase")]
42 UpsertCourseUnits {
43 course_units: Vec<CourseUnitUpsert>,
44 },
45 UpsertEnrolments {
46 enrolments: Vec<EnrolmentUpsert>,
47 },
48 UpsertAttainments {
49 attainments: Vec<AttainmentUpsert>,
50 },
51 #[serde(rename_all = "camelCase")]
52 DeletePersons {
53 student_numbers: Vec<String>,
54 },
55 AllocatePerson(AllocatePerson),
56 #[serde(rename_all = "camelCase")]
57 GenerateRoster {
58 course_code: String,
59 realisation_id: String,
60 count: u32,
61 #[serde(default)]
62 student_number_prefix: Option<String>,
63 },
64 #[serde(rename_all = "camelCase")]
65 SetPersonBehaviour {
66 student_number: String,
67 patch: PersonBehaviourPatch,
68 },
69 #[serde(rename_all = "camelCase")]
70 SetCourseBehaviour {
71 course_code: String,
72 patch: CourseBehaviourPatch,
73 },
74 #[serde(rename_all = "camelCase")]
75 TransitionSubmission {
76 submitted_attainment_id: String,
77 to: SubmissionTarget,
78 },
79 #[serde(rename_all = "camelCase")]
80 TransitionSubmissionsFor {
81 student_number: String,
82 course_code: Option<String>,
83 to: SubmissionTarget,
84 },
85 ListSubmissions(SubmissionFilter),
86 ArmFault(super::faults::FaultSpec),
87 DisarmFault {
88 id: String,
89 },
90 DisarmFaults {
91 owner: OwnerRef,
92 },
93 ListFaults(FaultFilter),
94 SetDefaults {
95 patch: DefaultsPatch,
96 },
97 ApplyScenario {
98 name: String,
99 #[serde(default)]
100 args: scenarios::ScenarioArgs,
101 },
102 ListCalls(CallFilter),
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub enum ResetScope {
108 World,
109 Faults,
110 Calls,
111 Persons(PersonScope),
112}
113
114#[derive(Debug, Default, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct PersonScope {
117 pub student_numbers: Option<Vec<String>>,
118 pub owner: Option<OwnerRef>,
119}
120
121#[derive(Debug, Default, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct WorldPush {
124 pub defaults: Option<WorldDefaults>,
125 #[serde(default)]
126 pub persons: Vec<PersonUpsert>,
127 #[serde(default)]
128 pub course_units: Vec<CourseUnitUpsert>,
129 #[serde(default)]
130 pub enrolments: Vec<EnrolmentUpsert>,
131 #[serde(default)]
132 pub attainments: Vec<AttainmentUpsert>,
133 #[serde(default)]
134 pub submissions: Vec<MockSubmission>,
135 #[serde(default)]
136 pub sisu_violations: Vec<SisuViolationsUpsert>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct SisuViolationsUpsert {
144 pub student_number: String,
145 pub course_code: String,
146 pub violations: Vec<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct PersonUpsert {
152 pub student_number: String,
153 pub person_id: Option<String>,
154 pub first_names: Option<String>,
155 pub last_name: Option<String>,
156 pub primary_email: Option<String>,
157 pub secondary_email: Option<String>,
158 #[serde(default)]
159 pub behaviour: PersonBehaviour,
160 pub owner_user_email: Option<String>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct RealisationUpsert {
166 pub id: Option<String>,
167 pub name: Option<LocalizedName>,
168 pub assessment_item_id: Option<String>,
169 #[serde(default = "degree")]
170 pub kind: RealisationKind,
171 pub activity_period: Option<DatePeriod>,
172 pub grade_scale_id: Option<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct CourseUnitUpsert {
179 pub course_code: String,
180 pub course_unit_id: Option<String>,
181 pub name: Option<LocalizedName>,
182 pub credits: Option<CreditRange>,
183 pub grade_scale_id: Option<String>,
184 #[serde(default)]
185 pub realisations: Vec<RealisationUpsert>,
186 pub suotar_course: Option<SuotarCourse>,
188 #[serde(default)]
189 pub behaviour: CourseBehaviour,
190 pub owner_course_slug: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase")]
195pub struct EnrolmentUpsert {
196 pub id: Option<String>,
197 pub student_number: String,
198 pub course_code: String,
199 pub realisation_id: Option<String>,
200 #[serde(default = "degree")]
201 pub kind: RealisationKind,
202 pub state: EnrolmentState,
203 #[serde(
205 default,
206 deserialize_with = "explicit_null",
207 skip_serializing_if = "Option::is_none"
208 )]
209 pub study_right_id: Option<Option<String>>,
210 pub study_right_validity_period: Option<DatePeriod>,
212 pub study_right_grant_date: Option<NaiveDate>,
213 #[serde(
215 default,
216 deserialize_with = "explicit_null",
217 skip_serializing_if = "Option::is_none"
218 )]
219 pub enrolment_date_time: Option<Option<DateTime<Utc>>>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct AttainmentUpsert {
225 pub id: Option<String>,
226 pub student_number: String,
227 pub course_code: String,
228 pub person_id: Option<String>,
229 #[serde(default = "degree")]
230 pub kind: RealisationKind,
231 pub attainment_type: Option<String>,
232 pub state: Option<AttainmentState>,
233 pub attainment_date: NaiveDate,
234 pub registration_date: Option<NaiveDate>,
235 pub grade_scale_id: String,
236 pub grade_id: String,
237 pub passed: Option<bool>,
238 pub credits: Option<f64>,
239}
240
241#[derive(Debug, Default, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct AllocatePerson {
244 pub first_names: Option<String>,
245 pub last_name: Option<String>,
246 pub primary_email: Option<String>,
247 pub secondary_email: Option<String>,
248 pub owner_user_email: Option<String>,
249}
250
251#[derive(Debug, Default, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct PersonBehaviourPatch {
254 pub study_right_unresolvable: Option<bool>,
255 pub primary_email: Option<String>,
256 pub secondary_email: Option<String>,
257}
258
259#[derive(Debug, Default, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct CourseBehaviourPatch {
262 pub no_acceptors: Option<bool>,
263 pub acceptor_lookup_fails: Option<bool>,
264}
265
266#[derive(Debug, Clone, Copy, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub enum SubmissionTarget {
269 Registered,
270 PartiallyRegistered,
271 Misregistered,
272 NotRegistered,
273 TimedOutButLanded,
274 TimedOutNothingLanded,
275}
276
277#[derive(Debug, Default, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct SubmissionFilter {
280 pub student_number: Option<String>,
281 pub course_code: Option<String>,
282}
283
284#[derive(Debug, Default, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct FaultFilter {
287 pub id: Option<String>,
288 pub owner: Option<OwnerRef>,
289}
290
291#[derive(Debug, Default, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct DefaultsPatch {
294 pub accepted_token: Option<String>,
295 pub grade_scales: Option<Vec<GradeScale>>,
296 pub call_log_capacity: Option<usize>,
297}
298
299#[derive(Debug, Default, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct CallFilter {
302 pub endpoint: Option<Endpoint>,
303 pub student_number: Option<String>,
304 pub course_code: Option<String>,
305 pub request_item_id: Option<String>,
306 pub fault_id: Option<String>,
307 pub correlation_id: Option<String>,
308 pub limit: Option<usize>,
309}
310
311#[derive(Debug, Serialize, Deserialize, PartialEq)]
312#[serde(rename_all = "camelCase", tag = "status")]
313pub enum CommandResult {
314 Ok {
315 command: String,
316 result: serde_json::Value,
317 },
318 Error {
319 command: Option<String>,
320 code: String,
321 message: String,
322 },
323 NotImplemented {
324 command: String,
325 },
326}
327
328pub struct CommandError {
329 pub code: String,
330 pub message: String,
331}
332
333impl CommandError {
334 pub fn new(code: &str, message: impl Into<String>) -> Self {
335 Self {
336 code: code.to_string(),
337 message: message.into(),
338 }
339 }
340}
341
342impl From<anyhow::Error> for CommandError {
343 fn from(error: anyhow::Error) -> Self {
344 Self::new("internalError", error.to_string())
345 }
346}
347
348type Outcome = Result<serde_json::Value, CommandError>;
349
350impl MockSuotarCommand {
351 pub fn name(&self) -> &'static str {
352 match self {
353 Self::Reset { .. } => "reset",
354 Self::PushWorld(_) => "pushWorld",
355 Self::UpsertPersons { .. } => "upsertPersons",
356 Self::UpsertCourseUnits { .. } => "upsertCourseUnits",
357 Self::UpsertEnrolments { .. } => "upsertEnrolments",
358 Self::UpsertAttainments { .. } => "upsertAttainments",
359 Self::DeletePersons { .. } => "deletePersons",
360 Self::AllocatePerson(_) => "allocatePerson",
361 Self::GenerateRoster { .. } => "generateRoster",
362 Self::SetPersonBehaviour { .. } => "setPersonBehaviour",
363 Self::SetCourseBehaviour { .. } => "setCourseBehaviour",
364 Self::TransitionSubmission { .. } => "transitionSubmission",
365 Self::TransitionSubmissionsFor { .. } => "transitionSubmissionsFor",
366 Self::ListSubmissions(_) => "listSubmissions",
367 Self::ArmFault(_) => "armFault",
368 Self::DisarmFault { .. } => "disarmFault",
369 Self::DisarmFaults { .. } => "disarmFaults",
370 Self::ListFaults(_) => "listFaults",
371 Self::SetDefaults { .. } => "setDefaults",
372 Self::ApplyScenario { .. } => "applyScenario",
373 Self::ListCalls(_) => "listCalls",
374 }
375 }
376}
377
378pub async fn execute(
379 store: &MockSuotarStore,
380 pool: &PgPool,
381 command: MockSuotarCommand,
382) -> CommandResult {
383 let name = command.name().to_string();
384 match run(store, pool, command).await {
385 Ok(result) => CommandResult::Ok {
386 command: name,
387 result,
388 },
389 Err(error) => CommandResult::Error {
390 command: Some(name),
391 code: error.code,
392 message: error.message,
393 },
394 }
395}
396
397async fn run(store: &MockSuotarStore, pool: &PgPool, command: MockSuotarCommand) -> Outcome {
398 if let MockSuotarCommand::Reset {
400 scope: ResetScope::World,
401 } = &command
402 {
403 store.flush().await?;
404 return Ok(json!({ "flushed": true }));
405 }
406 if let MockSuotarCommand::PushWorld(push) = command {
407 let marker = default_world::db_generation_marker(pool).await;
408 let world = world_from_push(push);
409 let counts = json!({
410 "persons": world.persons.len(),
411 "courseUnits": world.course_units.len(),
412 "enrolments": world.enrolments.len(),
413 "attainments": world.attainments.len(),
414 "submissions": world.submissions.len(),
415 "sisuViolations": world.sisu_violations.len(),
416 });
417 let generation = store.install_world(&world, marker.as_deref()).await?;
418 return Ok(json!({ "generation": generation, "counts": counts }));
419 }
420
421 let generation = current_generation(store, pool).await?;
422 match command {
423 MockSuotarCommand::Reset { scope } => reset(store, &generation, scope).await,
424 MockSuotarCommand::PushWorld(_) => unreachable!("handled above"),
425 MockSuotarCommand::UpsertPersons { persons } => {
426 upsert_command(
427 store,
428 &generation,
429 EntityHash::Persons,
430 "studentNumbers",
431 persons,
432 person_from,
433 |person| person.student_number.clone(),
434 )
435 .await
436 }
437 MockSuotarCommand::UpsertCourseUnits { course_units } => {
438 upsert_command(
439 store,
440 &generation,
441 EntityHash::CourseUnits,
442 "courseCodes",
443 course_units,
444 course_unit_from,
445 |unit| unit.course_code.clone(),
446 )
447 .await
448 }
449 MockSuotarCommand::UpsertEnrolments { enrolments } => {
450 upsert_command(
451 store,
452 &generation,
453 EntityHash::Enrolments,
454 "enrolmentIds",
455 enrolments,
456 enrolment_from,
457 |enrolment| enrolment.id.clone(),
458 )
459 .await
460 }
461 MockSuotarCommand::UpsertAttainments { attainments } => {
462 upsert_command(
463 store,
464 &generation,
465 EntityHash::Attainments,
466 "attainmentIds",
467 attainments,
468 attainment_from,
469 |attainment| attainment.id.clone(),
470 )
471 .await
472 }
473 MockSuotarCommand::DeletePersons { student_numbers } => {
474 delete_persons(store, &generation, &student_numbers).await
475 }
476 MockSuotarCommand::AllocatePerson(args) => allocate_person(store, &generation, args).await,
477 MockSuotarCommand::GenerateRoster {
478 course_code,
479 realisation_id,
480 count,
481 student_number_prefix,
482 } => {
483 generate_roster(
484 store,
485 &generation,
486 &course_code,
487 &realisation_id,
488 count,
489 student_number_prefix.as_deref(),
490 )
491 .await
492 }
493 MockSuotarCommand::SetPersonBehaviour {
494 student_number,
495 patch,
496 } => {
497 let mut person: MockPerson = store
498 .get_json(&generation, EntityHash::Persons, &student_number)
499 .await?
500 .ok_or_else(|| {
501 CommandError::new(
502 "unknownPerson",
503 format!("No person `{student_number}` in the world."),
504 )
505 })?;
506 if let Some(unresolvable) = patch.study_right_unresolvable {
507 person.behaviour.study_right_unresolvable = unresolvable;
508 }
509 if let Some(email) = patch.primary_email {
510 person.primary_email = Some(email);
511 }
512 if let Some(email) = patch.secondary_email {
513 person.secondary_email = Some(email);
514 }
515 store
516 .upsert_json(
517 &generation,
518 EntityHash::Persons,
519 &BTreeMap::from([(student_number.clone(), person)]),
520 )
521 .await?;
522 Ok(json!({ "studentNumber": student_number }))
523 }
524 MockSuotarCommand::SetCourseBehaviour { course_code, patch } => {
525 let mut unit: MockCourseUnit = store
526 .get_json(&generation, EntityHash::CourseUnits, &course_code)
527 .await?
528 .ok_or_else(|| {
529 CommandError::new(
530 "unknownCourseUnit",
531 format!("No course unit `{course_code}` in the world."),
532 )
533 })?;
534 if let Some(no_acceptors) = patch.no_acceptors {
535 unit.behaviour.no_acceptors = no_acceptors;
536 }
537 if let Some(fails) = patch.acceptor_lookup_fails {
538 unit.behaviour.acceptor_lookup_fails = fails;
539 }
540 store
541 .upsert_json(
542 &generation,
543 EntityHash::CourseUnits,
544 &BTreeMap::from([(course_code.clone(), unit)]),
545 )
546 .await?;
547 Ok(json!({ "courseCode": course_code }))
548 }
549 MockSuotarCommand::TransitionSubmission {
550 submitted_attainment_id,
551 to,
552 } => transition(store, &generation, &[submitted_attainment_id], to).await,
553 MockSuotarCommand::TransitionSubmissionsFor {
554 student_number,
555 course_code,
556 to,
557 } => {
558 let ids =
559 submission_ids_for(store, &generation, &student_number, course_code.as_deref())
560 .await?;
561 transition(store, &generation, &ids, to).await
562 }
563 MockSuotarCommand::ListSubmissions(filter) => {
564 let submissions: BTreeMap<String, MockSubmission> =
565 store.all_json(&generation, EntityHash::Submissions).await?;
566 let matching: Vec<&MockSubmission> = submissions
567 .values()
568 .filter(|submission| {
569 filter
570 .student_number
571 .as_ref()
572 .is_none_or(|value| &submission.student_number == value)
573 && filter
574 .course_code
575 .as_ref()
576 .is_none_or(|value| &submission.course_code == value)
577 })
578 .collect();
579 Ok(json!({ "submissions": matching }))
580 }
581 MockSuotarCommand::ArmFault(spec) => arm_fault(store, &generation, spec).await,
582 MockSuotarCommand::DisarmFault { id } => {
583 store
584 .disarm_faults(&generation, std::slice::from_ref(&id))
585 .await?;
586 Ok(json!({ "disarmed": [id] }))
587 }
588 MockSuotarCommand::DisarmFaults { owner } => {
589 let resolved = resolve_owner(store, &generation, &owner).await?;
590 let ids: Vec<String> = store
591 .faults(&generation)
592 .await?
593 .into_iter()
594 .filter(|fault| fault.owner.as_ref().is_some_and(|o| overlaps(o, &resolved)))
595 .map(|fault| fault.id)
596 .collect();
597 store.disarm_faults(&generation, &ids).await?;
598 Ok(json!({ "disarmed": ids }))
599 }
600 MockSuotarCommand::ListFaults(filter) => {
601 let remaining = store.remaining_budgets(&generation).await?;
602 let faults: Vec<serde_json::Value> = store
603 .faults(&generation)
604 .await?
605 .into_iter()
606 .filter(|fault| filter.id.as_ref().is_none_or(|id| &fault.id == id))
607 .map(|fault| {
608 let left = remaining.get(&fault.id).copied().unwrap_or(0);
609 let spent = fault.lifetime.budget().is_some() && left <= 0;
610 json!({ "fault": fault, "remaining": left, "spent": spent })
611 })
612 .collect();
613 Ok(json!({ "faults": faults }))
614 }
615 MockSuotarCommand::SetDefaults { patch } => {
616 let mut defaults = store.preamble(&generation).await?.defaults;
617 apply_defaults_patch(&mut defaults, patch);
618 store.set_defaults(&generation, &defaults).await?;
619 Ok(serde_json::to_value(&defaults).unwrap_or(serde_json::Value::Null))
620 }
621 MockSuotarCommand::ApplyScenario { name, args } => {
622 scenarios::apply(store, &generation, &name, args).await
623 }
624 MockSuotarCommand::ListCalls(filter) => list_calls(store, &generation, filter).await,
625 }
626}
627
628async fn upsert_command<U, T: Serialize>(
631 store: &MockSuotarStore,
632 generation: &str,
633 hash: EntityHash,
634 result_key: &'static str,
635 items: Vec<U>,
636 build: impl Fn(U) -> T,
637 key_of: impl Fn(&T) -> String,
638) -> Outcome {
639 let entries: BTreeMap<String, T> = items
640 .into_iter()
641 .map(|item| {
642 let entity = build(item);
643 (key_of(&entity), entity)
644 })
645 .collect();
646 let mut result = serde_json::Map::new();
647 result.insert(
648 result_key.to_string(),
649 json!(entries.keys().collect::<Vec<_>>()),
650 );
651 store.upsert_json(generation, hash, &entries).await?;
652 store.reindex(generation).await?;
653 Ok(serde_json::Value::Object(result))
654}
655
656async fn current_generation(
658 store: &MockSuotarStore,
659 pool: &PgPool,
660) -> Result<String, CommandError> {
661 if let Some(generation) = store.live_generation().await?
662 && store.preamble(&generation).await?.defaults_present
663 {
664 return Ok(generation);
665 }
666 let marker = default_world::db_generation_marker(pool).await;
667 Ok(store
668 .install_if_absent(&default_world::build(), marker.as_deref())
669 .await?)
670}
671
672async fn reset(store: &MockSuotarStore, generation: &str, scope: ResetScope) -> Outcome {
673 match scope {
674 ResetScope::World => unreachable!("world reset is handled in `run` before dispatch"),
675 ResetScope::Faults => {
676 store.clear_faults(generation).await?;
677 Ok(json!({ "cleared": "faults" }))
678 }
679 ResetScope::Calls => {
680 store.clear_hash(generation, EntityHash::Calls).await?;
681 Ok(json!({ "cleared": "calls" }))
682 }
683 ResetScope::Persons(scope) => {
684 let mut student_numbers = scope.student_numbers.unwrap_or_default();
685 if let Some(owner) = scope.owner {
686 let resolved = resolve_owner(store, generation, &owner).await?;
687 student_numbers.extend(resolved.student_numbers);
688 }
689 student_numbers.sort();
690 student_numbers.dedup();
691 delete_persons(store, generation, &student_numbers).await
692 }
693 }
694}
695
696async fn delete_persons(
698 store: &MockSuotarStore,
699 generation: &str,
700 student_numbers: &[String],
701) -> Outcome {
702 let submissions: BTreeMap<String, MockSubmission> =
703 store.all_json(generation, EntityHash::Submissions).await?;
704 let attainments: BTreeMap<String, MockAttainment> =
705 store.all_json(generation, EntityHash::Attainments).await?;
706 let enrolments: BTreeMap<String, MockEnrolment> =
707 store.all_json(generation, EntityHash::Enrolments).await?;
708
709 let doomed_submissions: Vec<String> = submissions
710 .values()
711 .filter(|s| student_numbers.contains(&s.student_number))
712 .map(|s| s.submitted_attainment_id.clone())
713 .collect();
714 let doomed_attainments: Vec<String> = attainments
715 .values()
716 .filter(|a| student_numbers.contains(&a.student_number))
717 .map(|a| a.id.clone())
718 .collect();
719 let doomed_enrolments: Vec<String> = enrolments
720 .values()
721 .filter(|e| student_numbers.contains(&e.student_number))
722 .map(|e| e.id.clone())
723 .collect();
724
725 store
726 .delete_fields(generation, EntityHash::Persons, student_numbers)
727 .await?;
728 store
729 .delete_fields(generation, EntityHash::Submissions, &doomed_submissions)
730 .await?;
731 store
732 .delete_fields(generation, EntityHash::Attainments, &doomed_attainments)
733 .await?;
734 store
735 .delete_fields(generation, EntityHash::Enrolments, &doomed_enrolments)
736 .await?;
737 store.reindex(generation).await?;
738 Ok(json!({
739 "studentNumbers": student_numbers,
740 "submissions": doomed_submissions,
741 "attainments": doomed_attainments,
742 "enrolments": doomed_enrolments,
743 }))
744}
745
746async fn allocate_person(
749 store: &MockSuotarStore,
750 generation: &str,
751 args: AllocatePerson,
752) -> Outcome {
753 let sequence = store.next_person_seq(generation).await?;
754 let student_number = format!("99{sequence:07}");
755 let person =
756 MockPerson {
757 person_id: ids::person_id(&student_number),
758 first_names: Some(args.first_names.unwrap_or_else(|| "Zzyzx".to_string())),
759 last_name: Some(args.last_name.unwrap_or_else(|| "Allocated".to_string())),
760 primary_email: Some(args.primary_email.unwrap_or_else(|| {
761 format!("zzyzx.allocated.{student_number}@helsinki.example.com")
762 })),
763 secondary_email: args.secondary_email,
764 behaviour: PersonBehaviour::default(),
765 owner_user_email: args.owner_user_email,
766 student_number: student_number.clone(),
767 };
768 let result = json!({ "studentNumber": student_number, "personId": person.person_id });
769 store
770 .upsert_json(
771 generation,
772 EntityHash::Persons,
773 &BTreeMap::from([(student_number, person)]),
774 )
775 .await?;
776 store.reindex(generation).await?;
777 Ok(result)
778}
779
780async fn generate_roster(
781 store: &MockSuotarStore,
782 generation: &str,
783 course_code: &str,
784 realisation_id: &str,
785 count: u32,
786 student_number_prefix: Option<&str>,
787) -> Outcome {
788 let unit: MockCourseUnit = store
789 .get_json(generation, EntityHash::CourseUnits, course_code)
790 .await?
791 .ok_or_else(|| {
792 CommandError::new(
793 "unknownCourseUnit",
794 format!("No course unit `{course_code}` in the world."),
795 )
796 })?;
797 let realisation = unit.realisation(realisation_id).cloned().ok_or_else(|| {
798 CommandError::new(
799 "unknownRealisation",
800 format!("`{realisation_id}` is not a realisation of `{course_code}`."),
801 )
802 })?;
803 let prefix = student_number_prefix.unwrap_or("99");
805 let now = Utc::now();
806
807 let mut persons = BTreeMap::new();
808 let mut enrolments = BTreeMap::new();
809 let mut student_numbers = Vec::new();
810 for _ in 0..count {
811 let sequence = store.next_person_seq(generation).await?;
812 let student_number = format!("{prefix}{sequence:07}");
813 student_numbers.push(student_number.clone());
814 persons.insert(
815 student_number.clone(),
816 MockPerson {
817 person_id: ids::person_id(&student_number),
818 first_names: Some("Zzyzx".to_string()),
819 last_name: Some(format!("Roster{sequence}")),
820 primary_email: Some(format!(
821 "zzyzx.roster.{student_number}@helsinki.example.com"
822 )),
823 secondary_email: None,
824 behaviour: PersonBehaviour::default(),
825 owner_user_email: None,
826 student_number: student_number.clone(),
827 },
828 );
829 let enrolment =
830 MockEnrolment::enrolled_now(&student_number, course_code, &realisation, now);
831 enrolments.insert(enrolment.id.clone(), enrolment);
832 }
833 store
834 .upsert_json(generation, EntityHash::Persons, &persons)
835 .await?;
836 store
837 .upsert_json(generation, EntityHash::Enrolments, &enrolments)
838 .await?;
839 store.reindex(generation).await?;
840 Ok(json!({
841 "courseCode": course_code,
842 "realisationId": realisation_id,
843 "studentNumbers": student_numbers,
844 }))
845}
846
847async fn submission_ids_for(
848 store: &MockSuotarStore,
849 generation: &str,
850 student_number: &str,
851 course_code: Option<&str>,
852) -> Result<Vec<String>, CommandError> {
853 let submissions: BTreeMap<String, MockSubmission> =
854 store.all_json(generation, EntityHash::Submissions).await?;
855 Ok(submissions
856 .values()
857 .filter(|submission| submission.student_number == student_number)
858 .filter(|submission| course_code.is_none_or(|code| submission.course_code == code))
859 .map(|submission| submission.submitted_attainment_id.clone())
860 .collect())
861}
862
863async fn transition(
867 store: &MockSuotarStore,
868 generation: &str,
869 ids: &[String],
870 to: SubmissionTarget,
871) -> Outcome {
872 let now = Utc::now();
873 let mut updated: BTreeMap<String, MockSubmission> = BTreeMap::new();
874 let mut minted: BTreeMap<String, MockAttainment> = BTreeMap::new();
875 let mut retired: Vec<String> = Vec::new();
876 let defaults = store.preamble(generation).await?.defaults;
877
878 for id in ids {
879 let Some(mut submission): Option<MockSubmission> = store
880 .get_json(generation, EntityHash::Submissions, id)
881 .await?
882 else {
883 return Err(CommandError::new(
884 "unknownSubmission",
885 format!("No submission `{id}` in the world."),
886 ));
887 };
888 let final_id = ids::final_attainment_id(id);
889 retired.extend([id.clone(), final_id.clone()]);
890 let mut mint = |attainment_id: &str, level: AttainmentLevel, state: AttainmentState| {
891 let attainment = MockAttainment::from_submission(
892 &submission,
893 attainment_id,
894 level,
895 state,
896 &defaults,
897 now,
898 );
899 minted.insert(attainment_id.to_string(), attainment);
900 };
901 let importer = match to {
902 SubmissionTarget::Registered | SubmissionTarget::TimedOutButLanded => {
903 mint(&final_id, AttainmentLevel::Final, AttainmentState::Attained);
904 ImporterVisibility::Final {
905 attainment_id: final_id,
906 }
907 }
908 SubmissionTarget::PartiallyRegistered => {
909 mint(id, AttainmentLevel::Partial, AttainmentState::Attained);
910 ImporterVisibility::Partial {
911 attainment_id: id.clone(),
912 }
913 }
914 SubmissionTarget::Misregistered => {
915 mint(
916 &final_id,
917 AttainmentLevel::Final,
918 AttainmentState::Misregistered,
919 );
920 ImporterVisibility::Misregistered {
921 attainment_id: final_id,
922 }
923 }
924 SubmissionTarget::NotRegistered | SubmissionTarget::TimedOutNothingLanded => {
925 ImporterVisibility::None
926 }
927 };
928 submission.importer = importer;
929 match to {
930 SubmissionTarget::NotRegistered => submission.send_state = SendState::Rejected,
931 SubmissionTarget::TimedOutButLanded => submission.send_state = SendState::Attempted,
932 SubmissionTarget::TimedOutNothingLanded => {
933 submission.send_state = SendState::Attempted;
934 submission.created_at = submission
935 .created_at
936 .min(now - chrono::Duration::hours(PENDING_WINDOW_HOURS + 1));
937 }
938 SubmissionTarget::Misregistered => {
940 submission.created_at = submission
941 .created_at
942 .min(now - chrono::Duration::hours(super::logic::RECENTLY_ACCEPTED_HOURS + 1));
943 }
944 SubmissionTarget::Registered | SubmissionTarget::PartiallyRegistered => {}
945 }
946 updated.insert(id.clone(), submission);
947 }
948
949 retired.retain(|id| !minted.contains_key(id));
950 store
951 .delete_fields(generation, EntityHash::Attainments, &retired)
952 .await?;
953 store
954 .upsert_json(generation, EntityHash::Submissions, &updated)
955 .await?;
956 store
957 .upsert_json(generation, EntityHash::Attainments, &minted)
958 .await?;
959 store.reindex(generation).await?;
960 Ok(json!({
961 "submittedAttainmentIds": updated.keys().collect::<Vec<_>>(),
962 "attainmentIds": minted.keys().collect::<Vec<_>>(),
963 }))
964}
965
966pub async fn arm_fault(
967 store: &MockSuotarStore,
968 generation: &str,
969 spec: super::faults::FaultSpec,
970) -> Outcome {
971 let (fault, _) = build_fault(store, generation, spec).await?;
972 let result = json!({
973 "id": fault.id,
974 "parallelSafe": fault.parallel_safe,
975 "owner": fault.owner,
976 "seq": fault.seq,
977 });
978 store.arm_fault(generation, &fault).await?;
979 Ok(result)
980}
981
982async fn build_fault(
983 store: &MockSuotarStore,
984 generation: &str,
985 spec: super::faults::FaultSpec,
986) -> Result<(Fault, (Endpoint, Stage)), CommandError> {
987 let predicates = spec.when.into_predicates();
988 let validated = validate(&predicates, &spec.then, spec.proves_double_submission)
989 .map_err(|problem| CommandError::new(&problem.code, problem.message))?;
990 let owner = match predicates.iter().find_map(|predicate| match predicate {
991 Predicate::Owner(owner) => Some(owner.clone()),
992 _ => None,
993 }) {
994 Some(owner) => Some(resolve_owner(store, generation, &owner).await?),
995 None => None,
996 };
997 let parallel_safe = predicates.iter().any(|predicate| {
998 matches!(
999 predicate,
1000 Predicate::Owner(_) | Predicate::StudentNumber(_) | Predicate::CourseCode(_)
1001 )
1002 });
1003 let seq = store.next_fault_seq(generation).await?;
1004 Ok((
1005 Fault {
1006 id: spec.id,
1007 seq,
1008 when: predicates,
1009 then: spec.then,
1010 lifetime: spec.lifetime,
1011 proves_double_submission: spec.proves_double_submission,
1012 owner,
1013 parallel_safe,
1014 armed_at: Utc::now(),
1015 },
1016 validated,
1017 ))
1018}
1019
1020async fn resolve_owner(
1021 store: &MockSuotarStore,
1022 generation: &str,
1023 owner: &OwnerRef,
1024) -> Result<ResolvedOwner, CommandError> {
1025 let mut resolved = ResolvedOwner {
1026 user: owner.user.clone(),
1027 course: owner.course.clone(),
1028 ..Default::default()
1029 };
1030 for (half, prefix) in [
1031 (owner.user.as_ref(), "user"),
1032 (owner.course.as_ref(), "course"),
1033 ] {
1034 let Some(value) = half else { continue };
1035 let field = format!("{prefix}:{value}");
1036 let Some(keys): Option<OwnerKeys> = store.owner_keys(generation, &field).await? else {
1037 let known = store.known_owner_refs(generation).await?.join(", ");
1039 return Err(CommandError::new(
1040 "unknownOwner",
1041 format!("`{field}` names nobody in the world. It knows: {known}."),
1042 ));
1043 };
1044 if prefix == "user" {
1045 resolved.student_numbers = keys.student_numbers;
1046 } else {
1047 resolved.course_codes = keys.course_codes;
1048 }
1049 }
1050 Ok(resolved)
1051}
1052
1053fn overlaps(fault_owner: &ResolvedOwner, wanted: &ResolvedOwner) -> bool {
1054 let user_matches = wanted.user.is_some() && fault_owner.user == wanted.user;
1055 let course_matches = wanted.course.is_some() && fault_owner.course == wanted.course;
1056 user_matches || course_matches
1057}
1058
1059async fn list_calls(store: &MockSuotarStore, generation: &str, filter: CallFilter) -> Outcome {
1060 let limit = filter.limit.unwrap_or(DEFAULT_CALL_LIMIT);
1061 let calls = store.recent_calls(generation, limit).await?;
1062 let matching: Vec<&RecordedCall> = calls
1063 .iter()
1064 .filter(|call| {
1065 filter
1066 .endpoint
1067 .is_none_or(|endpoint| call.endpoint == endpoint)
1068 })
1069 .filter(|call| {
1070 filter
1071 .correlation_id
1072 .as_ref()
1073 .is_none_or(|id| call.correlation_id.as_ref() == Some(id))
1074 })
1075 .filter(|call| {
1076 filter
1077 .fault_id
1078 .as_ref()
1079 .is_none_or(|id| call.faults.applied.contains(id))
1080 })
1081 .filter(|call| {
1084 call.items.iter().any(|item| {
1085 filter
1086 .student_number
1087 .as_ref()
1088 .is_none_or(|value| item.student_number.as_ref() == Some(value))
1089 && filter
1090 .course_code
1091 .as_ref()
1092 .is_none_or(|value| item.course_code.as_ref() == Some(value))
1093 && filter
1094 .request_item_id
1095 .as_ref()
1096 .is_none_or(|value| &item.request_item_id == value)
1097 }) || (filter.student_number.is_none()
1098 && filter.course_code.is_none()
1099 && filter.request_item_id.is_none())
1100 })
1101 .collect();
1102 Ok(json!({ "calls": matching, "scanned": calls.len() }))
1103}
1104
1105fn apply_defaults_patch(defaults: &mut WorldDefaults, patch: DefaultsPatch) {
1106 if let Some(value) = patch.accepted_token {
1107 defaults.accepted_token = value;
1108 }
1109 if let Some(value) = patch.grade_scales {
1110 defaults.grade_scales = value;
1111 }
1112 if let Some(value) = patch.call_log_capacity {
1113 defaults.call_log_capacity = value;
1114 }
1115}
1116
1117pub fn world_from_push(push: WorldPush) -> World {
1118 World {
1119 defaults: push.defaults.unwrap_or_default(),
1120 persons: push
1121 .persons
1122 .into_iter()
1123 .map(|person| (person.student_number.clone(), person_from(person)))
1124 .collect(),
1125 course_units: push
1126 .course_units
1127 .into_iter()
1128 .map(|unit| (unit.course_code.clone(), course_unit_from(unit)))
1129 .collect(),
1130 enrolments: push
1131 .enrolments
1132 .into_iter()
1133 .map(|enrolment| {
1134 let enrolment = enrolment_from(enrolment);
1135 (enrolment.id.clone(), enrolment)
1136 })
1137 .collect(),
1138 attainments: push
1139 .attainments
1140 .into_iter()
1141 .map(|attainment| {
1142 let attainment = attainment_from(attainment);
1143 (attainment.id.clone(), attainment)
1144 })
1145 .collect(),
1146 submissions: push
1147 .submissions
1148 .into_iter()
1149 .map(|submission| (submission.submitted_attainment_id.clone(), submission))
1150 .collect(),
1151 sisu_violations: push
1152 .sisu_violations
1153 .into_iter()
1154 .filter(|upsert| !upsert.violations.is_empty())
1155 .map(|upsert| {
1156 (
1157 person_course_key(&upsert.student_number, &upsert.course_code),
1158 upsert.violations,
1159 )
1160 })
1161 .collect(),
1162 }
1163}
1164
1165fn degree() -> RealisationKind {
1166 RealisationKind::Degree
1167}
1168
1169fn explicit_null<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
1171where
1172 D: Deserializer<'de>,
1173 T: Deserialize<'de>,
1174{
1175 Option::<T>::deserialize(deserializer).map(Some)
1176}
1177
1178fn person_from(upsert: PersonUpsert) -> MockPerson {
1179 MockPerson {
1180 person_id: upsert
1181 .person_id
1182 .unwrap_or_else(|| ids::person_id(&upsert.student_number)),
1183 student_number: upsert.student_number,
1184 first_names: upsert.first_names,
1185 last_name: upsert.last_name,
1186 primary_email: upsert.primary_email,
1187 secondary_email: upsert.secondary_email,
1188 behaviour: upsert.behaviour,
1189 owner_user_email: upsert.owner_user_email,
1190 }
1191}
1192
1193fn course_unit_from(upsert: CourseUnitUpsert) -> MockCourseUnit {
1194 let course_code = upsert.course_code;
1195 let name = upsert.name.unwrap_or_else(|| localized(&course_code));
1196 MockCourseUnit {
1197 course_unit_id: upsert
1198 .course_unit_id
1199 .unwrap_or_else(|| ids::course_unit_id(&course_code)),
1200 realisations: upsert
1201 .realisations
1202 .into_iter()
1203 .map(|realisation| MockRealisation {
1204 id: realisation
1205 .id
1206 .unwrap_or_else(|| ids::realisation_id(&course_code, realisation.kind)),
1207 name: Some(realisation.name.unwrap_or_else(|| name.clone())),
1208 assessment_item_id: realisation
1209 .assessment_item_id
1210 .unwrap_or_else(|| ids::assessment_item_id(&course_code, realisation.kind)),
1211 kind: realisation.kind,
1212 activity_period: realisation.activity_period,
1213 grade_scale_id: realisation.grade_scale_id,
1214 })
1215 .collect(),
1216 credits: upsert.credits,
1217 grade_scale_id: upsert.grade_scale_id,
1218 suotar_course: upsert.suotar_course,
1219 behaviour: upsert.behaviour,
1220 owner_course_slug: upsert.owner_course_slug,
1221 name,
1222 course_code,
1223 }
1224}
1225
1226fn enrolment_from(upsert: EnrolmentUpsert) -> MockEnrolment {
1227 MockEnrolment {
1228 id: upsert.id.unwrap_or_else(|| {
1229 ids::enrolment_id(&upsert.student_number, &upsert.course_code, upsert.kind)
1230 }),
1231 realisation_id: upsert
1232 .realisation_id
1233 .unwrap_or_else(|| ids::realisation_id(&upsert.course_code, upsert.kind)),
1234 study_right_id: upsert
1235 .study_right_id
1236 .unwrap_or_else(|| Some(ids::study_right_id(&upsert.student_number, upsert.kind))),
1237 study_right: upsert
1238 .study_right_validity_period
1239 .map(|validity| MockStudyRight {
1240 validity,
1241 grant_date: upsert.study_right_grant_date,
1242 }),
1243 enrolment_date_time: upsert
1244 .enrolment_date_time
1245 .unwrap_or_else(|| Some(Utc::now())),
1246 student_number: upsert.student_number,
1247 course_code: upsert.course_code,
1248 state: upsert.state,
1249 }
1250}
1251
1252fn attainment_from(upsert: AttainmentUpsert) -> MockAttainment {
1254 let attainment_type = upsert
1255 .attainment_type
1256 .unwrap_or_else(|| super::wire::COURSE_UNIT_ATTAINMENT.to_string());
1257 let is_assessment_item = attainment_type == super::wire::ASSESSMENT_ITEM_ATTAINMENT;
1258 MockAttainment {
1259 id: upsert.id.unwrap_or_else(|| {
1260 ids::pushed_attainment_id(
1261 &upsert.student_number,
1262 &upsert.course_code,
1263 &upsert.grade_id,
1264 )
1265 }),
1266 state: upsert.state.unwrap_or(AttainmentState::Attained),
1267 person_id: upsert
1268 .person_id
1269 .unwrap_or_else(|| ids::person_id(&upsert.student_number)),
1270 course_unit_id: ids::course_unit_id(&upsert.course_code),
1271 assessment_item_id: is_assessment_item
1272 .then(|| ids::assessment_item_id(&upsert.course_code, upsert.kind)),
1273 course_unit_realisation_id: is_assessment_item
1274 .then(|| ids::realisation_id(&upsert.course_code, upsert.kind)),
1275 attainment_type,
1276 registration_date: upsert.registration_date.unwrap_or(upsert.attainment_date),
1277 passed: Some(upsert.passed.unwrap_or(true)),
1278 credits: upsert.credits,
1279 attainment_date: upsert.attainment_date,
1280 grade_scale_id: upsert.grade_scale_id,
1281 grade_id: upsert.grade_id,
1282 student_number: upsert.student_number,
1283 course_code: upsert.course_code,
1284 from_submission: None,
1285 }
1286}
1287
1288fn localized(text: &str) -> LocalizedName {
1289 LocalizedName {
1290 fi: text.to_string(),
1291 sv: text.to_string(),
1292 en: text.to_string(),
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 #[test]
1302 fn the_shapes_the_typescript_client_sends_deserialize() {
1303 let armed: MockSuotarCommand = serde_json::from_value(json!({
1304 "command": "armFault",
1305 "id": "outage-503",
1306 "when": [
1307 { "endpoint": "import_attainments" },
1308 { "stage": "requestGate" },
1309 { "owner": { "user": "someone@example.com", "course": "crs-401" } }
1310 ],
1311 "then": { "kind": "requestLevel", "status": 503, "code": "serviceTemporarilyUnavailable" },
1312 "lifetime": { "matchingCalls": 1 }
1313 }))
1314 .expect("armFault");
1315 assert_eq!(armed.name(), "armFault");
1316
1317 let pushed: MockSuotarCommand = serde_json::from_value(json!({
1318 "command": "pushWorld",
1319 "persons": [{
1320 "studentNumber": "090001019",
1321 "firstNames": "Zzyzx",
1322 "lastName": "Happypath",
1323 "primaryEmail": "zzyzx.happypath@helsinki.example.com",
1324 "behaviour": { "studyRightUnresolvable": true }
1325 }],
1326 "courseUnits": [{
1327 "courseCode": "CRS-101",
1328 "credits": { "min": 5, "max": 5 },
1329 "gradeScaleId": "sis-hyl-hyv",
1330 "suotarCourse": { "name": "CRS-101" },
1331 "realisations": [{
1332 "kind": "openUniversity",
1333 "activityPeriod": { "startDate": "2026-01-01", "endDate": "2026-12-31" }
1334 }]
1335 }],
1336 "enrolments": [{
1337 "studentNumber": "090001019",
1338 "courseCode": "CRS-101",
1339 "state": "ENROLLED",
1340 "studyRightId": null
1341 }]
1342 }))
1343 .expect("pushWorld");
1344 assert_eq!(pushed.name(), "pushWorld");
1345
1346 let reset: MockSuotarCommand = serde_json::from_value(json!({
1347 "command": "reset",
1348 "scope": { "persons": { "studentNumbers": ["090001019"] } }
1349 }))
1350 .expect("reset persons");
1351 assert_eq!(reset.name(), "reset");
1352
1353 let world: MockSuotarCommand =
1354 serde_json::from_value(json!({ "command": "reset", "scope": "world" }))
1355 .expect("reset world");
1356 assert_eq!(world.name(), "reset");
1357 }
1358}