Skip to main content

headless_lms_server/controllers/mock_suotar/
commands.rs

1//! The control command RPC and the three inspection GETs.
2//!
3//! `execute` is a plain async function so the seed can drive the same surface from Rust.
4
5use 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    /// Never derived: null means no acceptor, which is how `acceptorNotFound` is reached from data
166    /// alone.
167    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    /// The one target field that may itself be `None`, so an absent key and an explicit `null` are
288    /// otherwise indistinguishable.
289    #[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    // `reset { world }` installs nothing: the next contract request builds the world lazily.
394    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
635/// The shared body behind every `Upsert*` command. `key_of` reads the id off the built entity rather
636/// than the wire type, so a derived id is what comes back under `result_key`.
637async 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
663/// Builds the world lazily if a command arrives before any contract request has.
664async 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        // `Reset { scope: World }` never reaches this match — `run()` intercepts it first.
682        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
704/// Destructive with no undo: nothing keeps a copy of a person a spec upserted.
705async 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
754/// Draws from a range disjoint from the seed's per-spec blocks. A convenience, not an isolation
755/// primitive.
756async 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    // A spec index owns only a hundred numbers, so a large roster has to come from the allocator
811    // range.
812    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            // A fault that can never match must not be armed silently.
1040            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        // Never installs one: a health check that built a world could not report an empty one.
1342        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
1429/// Nothing here is exported to utoipa or `bindings.ts`; no mock's DTOs are.
1430pub 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    /// Nothing generates the Playwright client from the Rust side, so a rename is only caught here.
1441    #[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}