1use 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 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
108async 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
145async 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}