1use 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 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
106async 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
127async 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
177async 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}