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