Skip to main content

headless_lms_server/controllers/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 headless_lms_utils::services::suotar::SuotarEndpoint;
13use serde_json::json;
14
15use crate::prelude::*;
16
17use super::commands::{CommandError, arm_fault};
18use super::faults::{Effect, FaultSpec, Lifetime, OwnerRef, Predicate, Stage, WhenSpec};
19use super::ids;
20use super::store::{EntityHash, MockSuotarStore};
21use super::world::{
22    CreditRange, DatePeriod, EnrolmentState, LocalizedName, MockCourseUnit, MockEnrolment,
23    MockPerson, MockRealisation, PersonBehaviour, RealisationKind, Ripeness,
24};
25
26const ACCEPTOR_PERSON_ID: &str = "hy-hlo-acceptor";
27const PASS_FAIL_SCALE: &str = "sis-hyl-hyv";
28
29#[derive(Debug, Default, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ScenarioArgs {
32    pub student_number: Option<String>,
33    pub course_code: Option<String>,
34    pub realisation_kind: Option<RealisationKind>,
35    pub owner: Option<OwnerRef>,
36    pub primary_email: Option<String>,
37    pub secondary_email: Option<String>,
38    pub first_names: Option<String>,
39    pub last_name: Option<String>,
40}
41
42pub const SCENARIOS: [&str; 2] = ["happy-path", "timeout-but-landed"];
43
44pub async fn apply(
45    store: &MockSuotarStore,
46    generation: &str,
47    name: &str,
48    args: ScenarioArgs,
49) -> Result<serde_json::Value, CommandError> {
50    if !SCENARIOS.contains(&name) {
51        return Err(CommandError::new(
52            "unknownScenario",
53            format!("No scenario `{name}`. Known: {}.", SCENARIOS.join(", ")),
54        ));
55    }
56    let mut result = match name {
57        "happy-path" => plain(store, generation, &args, Ripeness::Manual).await?,
58        "timeout-but-landed" => timeout(store, generation, &args).await?,
59        _ => unreachable!("checked against the catalogue above"),
60    };
61
62    // The scope comes from the owner the caller passed, not from the fixtures touched.
63    if let Some(object) = result.as_object_mut() {
64        object.insert("scenario".to_string(), json!(name));
65        match &args.owner {
66            Some(owner) if !owner.is_empty() => {
67                object.insert(
68                    "scope".to_string(),
69                    json!({ "courseSlug": owner.course, "userEmail": owner.user }),
70                );
71                object.insert("owner".to_string(), json!(owner));
72            }
73            _ => {
74                object.insert("scope".to_string(), serde_json::Value::Null);
75            }
76        }
77    }
78    Ok(result)
79}
80
81async fn plain(
82    store: &MockSuotarStore,
83    generation: &str,
84    args: &ScenarioArgs,
85    ripeness: Ripeness,
86) -> Result<serde_json::Value, CommandError> {
87    let realisation = ensure_course(store, generation, args).await?;
88    let student_number = put_person(store, generation, args, Some(ripeness)).await?;
89    let course_code = course_code(args)?;
90    let enrolment_id = put_enrolment(
91        store,
92        generation,
93        &student_number,
94        &course_code,
95        &realisation,
96    )
97    .await?;
98    Ok(json!({
99        "studentNumber": student_number,
100        "personId": ids::person_id(&student_number),
101        "enrolmentId": enrolment_id,
102        "realisationId": realisation.id,
103        "courseCode": course_code,
104        "kind": realisation.kind,
105    }))
106}
107
108/// `sisuTimeout` after the write leaves the world indistinguishable from a successful import, which
109/// is the case a client cannot resolve without verifying.
110async fn timeout(
111    store: &MockSuotarStore,
112    generation: &str,
113    args: &ScenarioArgs,
114) -> Result<serde_json::Value, CommandError> {
115    let mut base = plain(store, generation, args, Ripeness::Manual).await?;
116    let student_number = string_field(&base, "studentNumber")?;
117    let fault_id = format!("timeout-{student_number}");
118    arm(
119        store,
120        generation,
121        &fault_id,
122        vec![
123            Predicate::Endpoint(SuotarEndpoint::ImportAttainments),
124            Predicate::Stage(Stage::AfterWrite),
125            Predicate::StudentNumber(student_number.clone()),
126        ],
127        Effect::ItemLevel {
128            code: "sisuTimeout".to_string(),
129            message: None,
130            disclose_submitted_attainment_id: true,
131        },
132        Lifetime {
133            matching_items: Some(1),
134            ..Default::default()
135        },
136    )
137    .await?;
138    merge(
139        &mut base,
140        json!({ "faultId": fault_id, "discloseId": true }),
141    );
142    Ok(base)
143}
144
145/// A five-credit pass/fail realisation with an acceptor: the scenarios differ in the fault they arm,
146/// not in their data.
147async fn ensure_course(
148    store: &MockSuotarStore,
149    generation: &str,
150    args: &ScenarioArgs,
151) -> Result<MockRealisation, CommandError> {
152    let course_code = course_code(args)?;
153    let kind = args.realisation_kind.unwrap_or(RealisationKind::Degree);
154    let now = Utc::now();
155    let realisation = MockRealisation {
156        id: ids::realisation_id(&course_code, kind),
157        name: LocalizedName {
158            fi: course_code.clone(),
159            sv: course_code.clone(),
160            en: course_code.clone(),
161        },
162        assessment_item_id: ids::assessment_item_id(&course_code, kind),
163        kind,
164        activity_period: DatePeriod {
165            start_date: (now - Duration::days(180)).date_naive(),
166            end_date: (now + Duration::days(180)).date_naive(),
167        },
168        grade_scale_id: PASS_FAIL_SCALE.to_string(),
169        credits: CreditRange { min: 5.0, max: 5.0 },
170        acceptor_person_id: Some(ACCEPTOR_PERSON_ID.to_string()),
171        open_university_product_id: None,
172    };
173
174    let mut unit: MockCourseUnit = store
175        .get_json(generation, EntityHash::CourseUnits, &course_code)
176        .await?
177        .unwrap_or_else(|| MockCourseUnit {
178            course_unit_id: ids::course_unit_id(&course_code),
179            name: LocalizedName {
180                fi: course_code.clone(),
181                sv: course_code.clone(),
182                en: course_code.clone(),
183            },
184            realisations: Vec::new(),
185            behaviour: Default::default(),
186            owner_course_slug: args.owner.as_ref().and_then(|owner| owner.course.clone()),
187            course_code: course_code.clone(),
188        });
189    unit.behaviour.import_allowed = true;
190    unit.realisations
191        .retain(|existing| existing.id != realisation.id);
192    unit.realisations.push(realisation.clone());
193    store
194        .upsert_json(
195            generation,
196            EntityHash::CourseUnits,
197            &BTreeMap::from([(course_code, unit)]),
198        )
199        .await?;
200    store.reindex(generation).await?;
201    Ok(realisation)
202}
203
204async fn put_person(
205    store: &MockSuotarStore,
206    generation: &str,
207    args: &ScenarioArgs,
208    ripeness: Option<Ripeness>,
209) -> Result<String, CommandError> {
210    let student_number = match &args.student_number {
211        Some(student_number) => student_number.clone(),
212        None => {
213            let sequence = store.next_person_seq(generation).await?;
214            format!("99{sequence:07}")
215        }
216    };
217    let person = MockPerson {
218        person_id: ids::person_id(&student_number),
219        first_names: args
220            .first_names
221            .clone()
222            .unwrap_or_else(|| "Zzyzx".to_string()),
223        last_name: args
224            .last_name
225            .clone()
226            .unwrap_or_else(|| "Scenario".to_string()),
227        primary_email: args
228            .primary_email
229            .clone()
230            .unwrap_or_else(|| format!("zzyzx.scenario.{student_number}@helsinki.example")),
231        secondary_email: args.secondary_email.clone(),
232        behaviour: PersonBehaviour {
233            ripeness,
234            duplicate_detection: None,
235        },
236        owner_user_email: args.owner.as_ref().and_then(|owner| owner.user.clone()),
237        student_number: student_number.clone(),
238    };
239    store
240        .upsert_json(
241            generation,
242            EntityHash::Persons,
243            &BTreeMap::from([(student_number.clone(), person)]),
244        )
245        .await?;
246    store.reindex(generation).await?;
247    Ok(student_number)
248}
249
250async fn put_enrolment(
251    store: &MockSuotarStore,
252    generation: &str,
253    student_number: &str,
254    course_code: &str,
255    realisation: &MockRealisation,
256) -> Result<String, CommandError> {
257    let now = Utc::now();
258    let enrolment_id = ids::enrolment_id(student_number, realisation.kind);
259    let enrolment = MockEnrolment {
260        id: enrolment_id.clone(),
261        student_number: student_number.to_string(),
262        course_code: course_code.to_string(),
263        realisation_id: realisation.id.clone(),
264        state: EnrolmentState::Enrolled,
265        study_right_id: ids::study_right_id(student_number, realisation.kind),
266        study_right_validity_period: DatePeriod {
267            start_date: (now - Duration::days(365)).date_naive(),
268            end_date: (now + Duration::days(365)).date_naive(),
269        },
270        enrolment_date_time: now,
271    };
272    store
273        .upsert_json(
274            generation,
275            EntityHash::Enrolments,
276            &BTreeMap::from([(enrolment_id.clone(), enrolment)]),
277        )
278        .await?;
279    store.reindex(generation).await?;
280    Ok(enrolment_id)
281}
282
283async fn arm(
284    store: &MockSuotarStore,
285    generation: &str,
286    id: &str,
287    when: Vec<Predicate>,
288    then: Effect,
289    lifetime: Lifetime,
290) -> Result<(), CommandError> {
291    arm_fault(
292        store,
293        generation,
294        FaultSpec {
295            id: id.to_string(),
296            when: WhenSpec::Predicates(when),
297            then,
298            lifetime,
299            proves_double_submission: false,
300        },
301    )
302    .await?;
303    Ok(())
304}
305
306fn course_code(args: &ScenarioArgs) -> Result<String, CommandError> {
307    args.course_code
308        .clone()
309        .ok_or_else(|| CommandError::new("missingArgument", "This scenario needs a courseCode."))
310}
311
312fn string_field(value: &serde_json::Value, field: &str) -> Result<String, CommandError> {
313    value
314        .get(field)
315        .and_then(|value| value.as_str())
316        .map(str::to_string)
317        .ok_or_else(|| CommandError::new("internalError", format!("scenario lost its {field}")))
318}
319
320fn merge(target: &mut serde_json::Value, extra: serde_json::Value) {
321    if let (Some(target), Some(extra)) = (target.as_object_mut(), extra.as_object()) {
322        for (key, value) in extra {
323            target.insert(key.clone(), value.clone());
324        }
325    }
326}