Skip to main content

headless_lms_server/mock_suotar/
scenarios.rs

1//! Named scenarios: small compositions of the control primitives, exposed as one command.
2//!
3//! A scenario earns its place only by composing something data alone cannot express — an armed fault
4//! — or by being a hands-free dev demo; per-spec fixtures come from the seed instead.
5//!
6//! Each writes the course unit for its `courseCode` whole, so its caller has to own that course code,
7//! and returns the identifiers it minted plus the scope its rows are ticked with.
8
9use std::collections::BTreeMap;
10
11use chrono::Duration;
12use serde_json::json;
13
14use crate::prelude::*;
15
16use super::commands::{CommandError, arm_fault};
17use super::faults::{Effect, FaultSpec, Lifetime, OwnerRef, Predicate, Stage, WhenSpec};
18use super::ids;
19use super::store::{EntityHash, MockSuotarStore};
20use super::wire::Endpoint;
21use super::world::{
22    CourseBehaviour, CreditRange, DatePeriod, LocalizedName, MockCourseUnit, MockEnrolment,
23    MockPerson, MockRealisation, PersonBehaviour, RealisationKind, SuotarCourse,
24};
25
26const PASS_FAIL_SCALE: &str = "sis-hyl-hyv";
27
28#[derive(Debug, Default, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ScenarioArgs {
31    pub student_number: Option<String>,
32    pub course_code: Option<String>,
33    pub realisation_kind: Option<RealisationKind>,
34    pub owner: Option<OwnerRef>,
35    pub primary_email: Option<String>,
36    pub secondary_email: Option<String>,
37    pub first_names: Option<String>,
38    pub last_name: Option<String>,
39}
40
41pub const SCENARIOS: [&str; 3] = ["happy-path", "timeout-but-landed", "import-unanswered"];
42
43pub async fn apply(
44    store: &MockSuotarStore,
45    generation: &str,
46    name: &str,
47    args: ScenarioArgs,
48) -> Result<serde_json::Value, CommandError> {
49    let mut result = match name {
50        "happy-path" => plain(store, generation, &args).await?,
51        "timeout-but-landed" => timeout(store, generation, &args).await?,
52        "import-unanswered" => unanswered(store, generation, &args).await?,
53        _ => {
54            return Err(CommandError::new(
55                "unknownScenario",
56                format!("No scenario `{name}`. Known: {}.", SCENARIOS.join(", ")),
57            ));
58        }
59    };
60
61    // The scope comes from the owner the caller passed, not from the fixtures touched.
62    if let Some(object) = result.as_object_mut() {
63        object.insert("scenario".to_string(), json!(name));
64        match &args.owner {
65            Some(owner) if !owner.is_empty() => {
66                object.insert(
67                    "scope".to_string(),
68                    json!({ "courseSlug": owner.course, "userEmail": owner.user }),
69                );
70                object.insert("owner".to_string(), json!(owner));
71            }
72            _ => {
73                object.insert("scope".to_string(), serde_json::Value::Null);
74            }
75        }
76    }
77    Ok(result)
78}
79
80async fn plain(
81    store: &MockSuotarStore,
82    generation: &str,
83    args: &ScenarioArgs,
84) -> Result<serde_json::Value, CommandError> {
85    let realisation = ensure_course(store, generation, args).await?;
86    let student_number = put_person(store, generation, args).await?;
87    let course_code = course_code(args)?;
88    let enrolment_id = put_enrolment(
89        store,
90        generation,
91        &student_number,
92        &course_code,
93        &realisation,
94    )
95    .await?;
96    Ok(json!({
97        "studentNumber": student_number,
98        "personId": ids::person_id(&student_number),
99        "enrolmentId": enrolment_id,
100        "realisationId": realisation.id,
101        "courseCode": course_code,
102        "kind": realisation.kind,
103    }))
104}
105
106/// `sisuTimeout` after the write, with the written submission's id in `result`: the case a client cannot
107/// resolve without verifying.
108async fn timeout(
109    store: &MockSuotarStore,
110    generation: &str,
111    args: &ScenarioArgs,
112) -> Result<serde_json::Value, CommandError> {
113    arm_after_import(
114        store,
115        generation,
116        args,
117        "timeout",
118        Stage::AfterWrite,
119        Effect::ItemLevel {
120            code: "sisuTimeout".to_string(),
121            message: None,
122        },
123    )
124    .await
125}
126
127/// The import lands but its item is missing from the response, so the client learns no id at all.
128async fn unanswered(
129    store: &MockSuotarStore,
130    generation: &str,
131    args: &ScenarioArgs,
132) -> Result<serde_json::Value, CommandError> {
133    arm_after_import(
134        store,
135        generation,
136        args,
137        "unanswered",
138        Stage::Respond,
139        Effect::DropItem,
140    )
141    .await
142}
143
144async fn arm_after_import(
145    store: &MockSuotarStore,
146    generation: &str,
147    args: &ScenarioArgs,
148    fault_prefix: &str,
149    stage: Stage,
150    effect: Effect,
151) -> Result<serde_json::Value, CommandError> {
152    let mut base = plain(store, generation, args).await?;
153    let student_number = string_field(&base, "studentNumber")?;
154    let course_code = string_field(&base, "courseCode")?;
155    let fault_id = format!("{fault_prefix}-{student_number}-{course_code}");
156    arm(
157        store,
158        generation,
159        &fault_id,
160        vec![
161            Predicate::Endpoint(Endpoint::ImportAttainments),
162            Predicate::Stage(stage),
163            Predicate::StudentNumber(student_number.clone()),
164            Predicate::CourseCode(course_code),
165        ],
166        effect,
167        Lifetime {
168            matching_items: Some(1),
169            ..Default::default()
170        },
171    )
172    .await?;
173    merge(&mut base, json!({ "faultId": fault_id }));
174    Ok(base)
175}
176
177/// A five-credit pass/fail course Suotar carries: the scenarios differ in the fault they arm, not in
178/// their data.
179async fn ensure_course(
180    store: &MockSuotarStore,
181    generation: &str,
182    args: &ScenarioArgs,
183) -> Result<MockRealisation, CommandError> {
184    let course_code = course_code(args)?;
185    let kind = args.realisation_kind.unwrap_or(RealisationKind::Degree);
186    let now = Utc::now();
187    let name = LocalizedName {
188        fi: course_code.clone(),
189        sv: course_code.clone(),
190        en: course_code.clone(),
191    };
192    let realisation = MockRealisation {
193        id: ids::realisation_id(&course_code, kind),
194        name: Some(name.clone()),
195        assessment_item_id: ids::assessment_item_id(&course_code, kind),
196        kind,
197        activity_period: Some(DatePeriod {
198            start_date: (now - Duration::days(180)).date_naive(),
199            end_date: Some((now + Duration::days(180)).date_naive()),
200        }),
201        grade_scale_id: None,
202    };
203
204    let mut unit: MockCourseUnit = store
205        .get_json(generation, EntityHash::CourseUnits, &course_code)
206        .await?
207        .unwrap_or_else(|| MockCourseUnit {
208            course_unit_id: ids::course_unit_id(&course_code),
209            name,
210            credits: None,
211            grade_scale_id: None,
212            realisations: Vec::new(),
213            suotar_course: None,
214            behaviour: CourseBehaviour::default(),
215            owner_course_slug: args.owner.as_ref().and_then(|owner| owner.course.clone()),
216            course_code: course_code.clone(),
217        });
218    unit.credits = Some(CreditRange {
219        min: 5.0,
220        max: Some(5.0),
221    });
222    unit.grade_scale_id = Some(PASS_FAIL_SCALE.to_string());
223    unit.suotar_course = Some(SuotarCourse {
224        name: course_code.clone(),
225    });
226    unit.behaviour = CourseBehaviour::default();
227    unit.realisations
228        .retain(|existing| existing.id != realisation.id);
229    unit.realisations.push(realisation.clone());
230    store
231        .upsert_json(
232            generation,
233            EntityHash::CourseUnits,
234            &BTreeMap::from([(course_code, unit)]),
235        )
236        .await?;
237    store.reindex(generation).await?;
238    Ok(realisation)
239}
240
241async fn put_person(
242    store: &MockSuotarStore,
243    generation: &str,
244    args: &ScenarioArgs,
245) -> Result<String, CommandError> {
246    let student_number = match &args.student_number {
247        Some(student_number) => student_number.clone(),
248        None => {
249            let sequence = store.next_person_seq(generation).await?;
250            format!("99{sequence:07}")
251        }
252    };
253    let person =
254        MockPerson {
255            person_id: ids::person_id(&student_number),
256            first_names: Some(
257                args.first_names
258                    .clone()
259                    .unwrap_or_else(|| "Zzyzx".to_string()),
260            ),
261            last_name: Some(
262                args.last_name
263                    .clone()
264                    .unwrap_or_else(|| "Scenario".to_string()),
265            ),
266            primary_email: Some(args.primary_email.clone().unwrap_or_else(|| {
267                format!("zzyzx.scenario.{student_number}@helsinki.example.com")
268            })),
269            secondary_email: args.secondary_email.clone(),
270            behaviour: PersonBehaviour::default(),
271            owner_user_email: args.owner.as_ref().and_then(|owner| owner.user.clone()),
272            student_number: student_number.clone(),
273        };
274    store
275        .upsert_json(
276            generation,
277            EntityHash::Persons,
278            &BTreeMap::from([(student_number.clone(), person)]),
279        )
280        .await?;
281    store.reindex(generation).await?;
282    Ok(student_number)
283}
284
285async fn put_enrolment(
286    store: &MockSuotarStore,
287    generation: &str,
288    student_number: &str,
289    course_code: &str,
290    realisation: &MockRealisation,
291) -> Result<String, CommandError> {
292    let enrolment =
293        MockEnrolment::enrolled_now(student_number, course_code, realisation, Utc::now());
294    let enrolment_id = enrolment.id.clone();
295    store
296        .upsert_json(
297            generation,
298            EntityHash::Enrolments,
299            &BTreeMap::from([(enrolment_id.clone(), enrolment)]),
300        )
301        .await?;
302    store.reindex(generation).await?;
303    Ok(enrolment_id)
304}
305
306async fn arm(
307    store: &MockSuotarStore,
308    generation: &str,
309    id: &str,
310    when: Vec<Predicate>,
311    then: Effect,
312    lifetime: Lifetime,
313) -> Result<(), CommandError> {
314    arm_fault(
315        store,
316        generation,
317        FaultSpec {
318            id: id.to_string(),
319            when: WhenSpec::Predicates(when),
320            then,
321            lifetime,
322            proves_double_submission: false,
323        },
324    )
325    .await?;
326    Ok(())
327}
328
329fn course_code(args: &ScenarioArgs) -> Result<String, CommandError> {
330    args.course_code
331        .clone()
332        .ok_or_else(|| CommandError::new("missingArgument", "This scenario needs a courseCode."))
333}
334
335fn string_field(value: &serde_json::Value, field: &str) -> Result<String, CommandError> {
336    value
337        .get(field)
338        .and_then(|value| value.as_str())
339        .map(str::to_string)
340        .ok_or_else(|| CommandError::new("internalError", format!("scenario lost its {field}")))
341}
342
343fn merge(target: &mut serde_json::Value, extra: serde_json::Value) {
344    if let (Some(target), Some(extra)) = (target.as_object_mut(), extra.as_object()) {
345        for (key, value) in extra {
346            target.insert(key.clone(), value.clone());
347        }
348    }
349}