Skip to main content

headless_lms_server/mock_suotar/
commands.rs

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