1use headless_lms_utils::services::suotar::SuotarEndpoint;
6
7use crate::prelude::*;
8
9use super::ids;
10use super::wire::{
11 self, ImportAttainmentResult, SuotarAttainment, SuotarResponseItem, error_item, ok_item,
12};
13use super::world::{
14 AttainmentState, DuplicateDetection, EnrolmentState, MockAttainment, MockCourseUnit,
15 MockEnrolment, MockRealisation, MockSubmission, Ripeness, SubmissionLifecycle, WorkingSet,
16 WorldWrite, person_course_key,
17};
18
19pub fn resolve_person_item(
20 item: &wire::ResolvePersonRequestItem,
21 working: &WorkingSet,
22) -> SuotarResponseItem<wire::PersonResult> {
23 let endpoint = SuotarEndpoint::ResolvePersons;
24 match working.persons.get(&item.student_number) {
25 Some(person) => ok_item(
26 &item.request_item_id,
27 "personFound",
28 wire::PersonResult {
29 student_number: item.student_number.clone(),
31 person_id: person.person_id.clone(),
32 first_names: person.first_names.clone(),
33 last_name: person.last_name.clone(),
34 },
35 ),
36 None => error_item(endpoint, &item.request_item_id, "personNotFound"),
37 }
38}
39
40pub fn resolve_enrolments_item(
41 item: &wire::ResolveEnrolmentRequestItem,
42 working: &mut WorkingSet,
43 now: DateTime<Utc>,
44) -> SuotarResponseItem<wire::EnrolmentResolutionResult> {
45 let endpoint = SuotarEndpoint::ResolveEnrolments;
46 let id = &item.request_item_id;
47 if !working.persons.contains_key(&item.student_number) {
48 return error_item(endpoint, id, "personNotFound");
49 }
50 let Some(course_unit) = working.course_units.get(&item.course_code).cloned() else {
51 return error_item(endpoint, id, "courseCodeNotFound");
52 };
53 ripen_person_course(working, &item.student_number, &item.course_code, now);
55
56 let matching: Vec<MockEnrolment> =
57 enrolments_for(working, &item.student_number, &item.course_code);
58 if matching.is_empty() {
59 return error_item(endpoint, id, "enrolmentNotFound");
60 }
61 if !matching
62 .iter()
63 .any(|enrolment| enrolment.state == EnrolmentState::Enrolled)
64 {
65 return error_item(endpoint, id, "enrolmentNotAccepted");
66 }
67
68 let mut listed: Vec<&MockEnrolment> = matching
69 .iter()
70 .filter(|enrolment| {
71 working.defaults.include_non_enrolled_in_result
72 || enrolment.state == EnrolmentState::Enrolled
73 })
74 .collect();
75 listed.sort_by_key(|enrolment| enrolment.enrolment_date_time);
76
77 let enrolments = listed
78 .into_iter()
79 .filter_map(|enrolment| {
80 course_unit
81 .realisation(&enrolment.realisation_id)
82 .map(|realisation| enrolment_dto(&course_unit, enrolment, realisation))
83 })
84 .collect();
85
86 ok_item(
87 id,
88 "enrolmentFound",
89 wire::EnrolmentResolutionResult {
90 enrolments,
91 existing_attainments: existing_attainments(
92 working,
93 &item.student_number,
94 &item.course_code,
95 ),
96 },
97 )
98}
99
100pub fn import_item(
101 item: &wire::ImportAttainmentRequestItem,
102 working: &mut WorkingSet,
103 now: DateTime<Utc>,
104) -> SuotarResponseItem<ImportAttainmentResult> {
105 let endpoint = SuotarEndpoint::ImportAttainments;
106 let id = &item.request_item_id;
107 let Some(person) = working.persons.get(&item.student_number).cloned() else {
108 return error_item(endpoint, id, "personNotFound");
109 };
110 let Some(course_unit) = working.course_units.get(&item.course_code).cloned() else {
112 return error_item(endpoint, id, "enrolmentNotFound");
113 };
114 let enrolment = working
115 .enrolments
116 .get(&item.enrolment_id)
117 .filter(|enrolment| {
118 enrolment.student_number == item.student_number
119 && enrolment.course_code == item.course_code
120 && enrolment.state == EnrolmentState::Enrolled
121 })
122 .cloned();
123 let Some(enrolment) = enrolment else {
124 return error_item(endpoint, id, "enrolmentNotFound");
125 };
126 if !course_unit.behaviour.import_allowed {
127 return error_item(endpoint, id, "courseNotAllowed");
128 }
129 let Some(realisation) = course_unit.realisation(&enrolment.realisation_id).cloned() else {
130 return error_item(endpoint, id, "enrolmentNotFound");
131 };
132
133 let scale = working.defaults.scale(&realisation.grade_scale_id);
134 let grade_matches_scale = working
135 .defaults
136 .scale(&item.grade_scale_id)
137 .zip(scale)
138 .is_some_and(|(requested, expected)| requested.id == expected.id);
139 let grade = scale.and_then(|scale| scale.grade(&item.grade_id));
140 if !grade_matches_scale || grade.is_none() {
141 return error_item(endpoint, id, "invalidGradeForGradeScale");
142 }
143 if item.credits < realisation.credits.min || item.credits > realisation.credits.max {
144 return error_item(endpoint, id, "invalidCredits");
145 }
146 if !enrolment
147 .study_right_validity_period
148 .contains(item.attainment_date)
149 {
150 return error_item(endpoint, id, "studyRightNotValid");
151 }
152 if realisation.acceptor_person_id.is_none() {
153 return error_item(endpoint, id, "acceptorNotFound");
154 }
155 if !realisation.activity_period.contains(item.attainment_date) {
156 return error_item(endpoint, id, "sisuValidationFailed");
157 }
158
159 ripen_person_course(working, &item.student_number, &item.course_code, now);
160
161 if working.duplicate_detection_for(&item.student_number) == DuplicateDetection::Detect
162 && let Some(outcome) = duplicate_outcome(item, working, &realisation)
163 {
164 return outcome;
165 }
166
167 let attempt = working
168 .submissions_by_person_course
169 .get(&person_course_key(&item.student_number, &item.course_code))
170 .map_or(0, |ids| ids.len()) as u32
171 + 1;
172 let submitted_attainment_id = ids::submitted_attainment_id(
173 &item.student_number,
174 &item.course_code,
175 &item.enrolment_id,
176 item.attainment_date,
177 &realisation.grade_scale_id,
178 &item.grade_id,
179 item.credits,
180 attempt,
181 );
182 let ripeness = working.ripeness_for(&item.student_number);
183 let submission = MockSubmission {
184 submitted_attainment_id: submitted_attainment_id.clone(),
185 submitted_attainment_type: "AssessmentItemAttainment".to_string(),
186 student_number: item.student_number.clone(),
187 course_code: item.course_code.clone(),
188 enrolment_id: item.enrolment_id.clone(),
189 realisation_id: realisation.id.clone(),
190 person_id: person.person_id.clone(),
191 course_unit_id: course_unit.course_unit_id.clone(),
192 assessment_item_id: realisation.assessment_item_id.clone(),
193 attainment_date: item.attainment_date,
194 attainment_language: item.attainment_language.clone(),
195 grade_scale_id: realisation.grade_scale_id.clone(),
196 grade_id: item.grade_id.clone(),
197 credits: item.credits,
198 lifecycle: SubmissionLifecycle::Pending { ripeness },
199 verify_calls: 0,
200 id_disclosed_to_client: false,
201 created_at: now,
202 };
203 record_submission(working, submission);
204
205 if ripen(working, &submitted_attainment_id, now)
206 && let Some(SubmissionLifecycle::Registered { attainment_id, .. }) = working
207 .submissions
208 .get(&submitted_attainment_id)
209 .map(|submission| submission.lifecycle.clone())
210 {
211 return ok_item(
212 id,
213 "registered",
214 ImportAttainmentResult {
215 attainment: Some(attainment_reference(attainment_id)),
216 ..Default::default()
217 },
218 );
219 }
220
221 ok_item(
222 id,
223 "sent",
224 ImportAttainmentResult {
225 submitted_attainment_id: Some(submitted_attainment_id),
226 submitted_attainment_type: Some("AssessmentItemAttainment".to_string()),
227 ..Default::default()
228 },
229 )
230}
231
232pub fn verify_item(
233 item: &wire::VerifyAttainmentRequestItem,
234 working: &mut WorkingSet,
235 now: DateTime<Utc>,
236) -> SuotarResponseItem<wire::VerifyAttainmentResult> {
237 let endpoint = SuotarEndpoint::VerifyAttainments;
238 let id = &item.request_item_id;
239 let Some(submission) = working.submissions.get_mut(&item.submitted_attainment_id) else {
242 return error_item(endpoint, id, "notRegistered");
243 };
244 submission.verify_calls += 1;
245 working.writes.push(WorldWrite::UpsertSubmission(
246 item.submitted_attainment_id.clone(),
247 ));
248
249 ripen(working, &item.submitted_attainment_id, now);
250
251 let Some(submission) = working.submissions.get(&item.submitted_attainment_id) else {
252 return error_item(endpoint, id, "notRegistered");
253 };
254 match &submission.lifecycle {
255 SubmissionLifecycle::Registered { attainment_id, .. } => ok_item(
256 id,
257 "registered",
258 wire::VerifyAttainmentResult {
259 attainment: attainment_reference(attainment_id.clone()),
260 },
261 ),
262 SubmissionLifecycle::Misregistered { .. } => error_item(endpoint, id, "misregistered"),
263 _ => error_item(endpoint, id, "notRegistered"),
264 }
265}
266
267pub fn product_access_token_item(
268 item: &wire::ProductAccessTokenRequestItem,
269 working: &WorkingSet,
270) -> SuotarResponseItem<wire::ProductAccessTokenResult> {
271 let endpoint = SuotarEndpoint::ProductAccessTokens;
272 match working.product_tokens.get(&item.open_university_product_id) {
273 Some(token) => ok_item(
276 &item.request_item_id,
277 "found",
278 wire::ProductAccessTokenResult {
279 id: token.id.clone(),
280 access_token: token.access_token.clone(),
281 state: serde_plain(&token.state),
282 document_state: serde_plain(&token.document_state),
283 },
284 ),
285 None => error_item(
286 endpoint,
287 &item.request_item_id,
288 "productAccessTokenNotFound",
289 ),
290 }
291}
292
293pub fn list_by_course_item(
294 item: &wire::ListByCourseRequestItem,
295 working: &WorkingSet,
296) -> SuotarResponseItem<wire::EnrolmentsListedResult> {
297 let endpoint = SuotarEndpoint::ListByCourse;
298 let id = &item.request_item_id;
299 let Some(course_unit) = working.course_units.get(&item.course_code) else {
300 return error_item(endpoint, id, "courseCodeNotFound");
301 };
302 let realisation_ids: Vec<String> = match &item.course_unit_realisation_id {
304 Some(realisation_id) => {
305 if course_unit.realisation(realisation_id).is_none() {
306 return error_item(endpoint, id, "courseCodeNotFound");
307 }
308 vec![realisation_id.clone()]
309 }
310 None => course_unit
311 .realisations
312 .iter()
313 .map(|realisation| realisation.id.clone())
314 .collect(),
315 };
316
317 let mut people: Vec<wire::ListedPerson> = realisation_ids
318 .iter()
319 .filter_map(|realisation_id| working.enrolments_by_realisation.get(realisation_id))
320 .flatten()
321 .filter_map(|enrolment_id| working.enrolments.get(enrolment_id))
322 .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
323 .filter_map(|enrolment| {
324 let person = working.persons.get(&enrolment.student_number)?;
325 Some(wire::ListedPerson {
326 student_number: person.student_number.clone(),
327 person_id: person.person_id.clone(),
328 first_names: person.first_names.clone(),
329 last_name: person.last_name.clone(),
330 primary_email: person.primary_email.clone(),
331 secondary_email: person.secondary_email.clone(),
332 enrolment: wire::ListedEnrolment {
333 id: enrolment.id.clone(),
334 course_unit_realisation_id: enrolment.realisation_id.clone(),
335 state: "ENROLLED".to_string(),
336 enrolment_date_time: enrolment.enrolment_date_time,
337 },
338 })
339 })
340 .collect();
341 people.sort_by(|a, b| a.student_number.cmp(&b.student_number));
342
343 ok_item(
344 id,
345 "enrolmentsListed",
346 wire::EnrolmentsListedResult { people },
347 )
348}
349
350pub fn ripen_person_course(
353 working: &mut WorkingSet,
354 student_number: &str,
355 course_code: &str,
356 now: DateTime<Utc>,
357) {
358 let ids = working
359 .submissions_by_person_course
360 .get(&person_course_key(student_number, course_code))
361 .cloned()
362 .unwrap_or_default();
363 for id in ids {
364 ripen(working, &id, now);
365 }
366}
367
368pub fn ripen(working: &mut WorkingSet, submitted_attainment_id: &str, now: DateTime<Utc>) -> bool {
370 let Some(submission) = working.submissions.get(submitted_attainment_id) else {
371 return false;
372 };
373 let ripeness = match &submission.lifecycle {
374 SubmissionLifecycle::Pending { ripeness }
375 | SubmissionLifecycle::TimedOutButLanded { ripeness } => *ripeness,
376 _ => return false,
377 };
378 let ripe = match ripeness {
379 Ripeness::AtImport => true,
380 Ripeness::Manual => false,
381 Ripeness::AutoAfterVerifyCalls { calls } => submission.verify_calls > calls,
382 };
383 if !ripe {
384 return false;
385 }
386 register(working, submitted_attainment_id, now);
387 true
388}
389
390pub fn register(working: &mut WorkingSet, submitted_attainment_id: &str, now: DateTime<Utc>) {
391 let Some(submission) = working.submissions.get(submitted_attainment_id).cloned() else {
392 return;
393 };
394 let attainment_id = ids::final_attainment_id(submitted_attainment_id);
395 let attainment = MockAttainment::from_submission(
396 &submission,
397 &attainment_id,
398 AttainmentState::Attained,
399 &working.defaults,
400 now,
401 );
402 let key = person_course_key(&submission.student_number, &submission.course_code);
403 working
404 .attainments
405 .insert(attainment_id.clone(), attainment);
406 let index = working.attainments_by_person_course.entry(key).or_default();
407 if !index.contains(&attainment_id) {
408 index.push(attainment_id.clone());
409 }
410 if let Some(submission) = working.submissions.get_mut(submitted_attainment_id) {
411 submission.lifecycle = SubmissionLifecycle::Registered {
412 attainment_id: attainment_id.clone(),
413 registered_at: now,
414 };
415 }
416 working.writes.push(WorldWrite::UpsertSubmission(
417 submitted_attainment_id.to_string(),
418 ));
419 working
420 .writes
421 .push(WorldWrite::UpsertAttainment(attainment_id.clone()));
422 working.writes.push(WorldWrite::IndexAttainment {
423 student_number: submission.student_number.clone(),
424 course_code: submission.course_code.clone(),
425 id: attainment_id,
426 });
427}
428
429pub fn record_submission(working: &mut WorkingSet, submission: MockSubmission) {
430 let id = submission.submitted_attainment_id.clone();
431 let key = person_course_key(&submission.student_number, &submission.course_code);
432 let student_number = submission.student_number.clone();
433 let course_code = submission.course_code.clone();
434 working.submissions.insert(id.clone(), submission);
435 let index = working.submissions_by_person_course.entry(key).or_default();
436 if !index.contains(&id) {
437 index.push(id.clone());
438 }
439 working
440 .writes
441 .push(WorldWrite::UpsertSubmission(id.clone()));
442 working.writes.push(WorldWrite::IndexSubmission {
443 student_number,
444 course_code,
445 id,
446 });
447}
448
449fn duplicate_outcome(
450 item: &wire::ImportAttainmentRequestItem,
451 working: &WorkingSet,
452 realisation: &MockRealisation,
453) -> Option<SuotarResponseItem<ImportAttainmentResult>> {
454 let scale = working.defaults.scale(&realisation.grade_scale_id)?;
455 let incoming_rank = scale.grade(&item.grade_id)?.rank;
456 for attainment in attained(working, &item.student_number, &item.course_code) {
457 if !scale.answers_to(&attainment.grade_scale_id) {
459 continue;
460 }
461 if attainment.grade_id == item.grade_id
462 && attainment.attainment_date == item.attainment_date
463 {
464 return Some(ok_item(
465 &item.request_item_id,
466 "duplicateAttainment",
467 ImportAttainmentResult {
468 attainment: Some(attainment_summary(attainment)),
469 ..Default::default()
470 },
471 ));
472 }
473 let existing_rank = scale
474 .grade(&attainment.grade_id)
475 .map_or(0, |grade| grade.rank);
476 if existing_rank >= incoming_rank {
477 return Some(ok_item(
478 &item.request_item_id,
479 "notImprovedAttainment",
480 ImportAttainmentResult {
481 previous_attainment: Some(attainment_summary(attainment)),
482 ..Default::default()
483 },
484 ));
485 }
486 }
487 None
488}
489
490fn attained<'a>(
491 working: &'a WorkingSet,
492 student_number: &str,
493 course_code: &str,
494) -> Vec<&'a MockAttainment> {
495 let mut found: Vec<&MockAttainment> = working
496 .attainments_by_person_course
497 .get(&person_course_key(student_number, course_code))
498 .into_iter()
499 .flatten()
500 .filter_map(|id| working.attainments.get(id))
501 .filter(|attainment| attainment.state == AttainmentState::Attained)
502 .collect();
503 found.sort_by(|a, b| {
504 a.attainment_date
505 .cmp(&b.attainment_date)
506 .then_with(|| a.id.cmp(&b.id))
507 });
508 found
509}
510
511fn existing_attainments(
512 working: &WorkingSet,
513 student_number: &str,
514 course_code: &str,
515) -> Vec<wire::ExistingAttainment> {
516 attained(working, student_number, course_code)
517 .into_iter()
518 .map(|attainment| wire::ExistingAttainment {
519 id: attainment.id.clone(),
520 attainment_type: attainment.attainment_type.clone(),
521 state: "ATTAINED".to_string(),
522 person_id: attainment.person_id.clone(),
523 course_unit_id: attainment.course_unit_id.clone(),
524 assessment_item_id: attainment.assessment_item_id.clone(),
525 course_unit_realisation_id: attainment.course_unit_realisation_id.clone(),
526 attainment_date: attainment.attainment_date,
527 registration_date: attainment.registration_date,
528 grade_scale_id: attainment.grade_scale_id.clone(),
529 grade_id: attainment.grade_id.clone(),
530 passed: attainment.passed,
531 })
532 .collect()
533}
534
535fn attainment_reference(id: String) -> SuotarAttainment {
537 SuotarAttainment {
538 id,
539 attainment_type: "CourseUnitAttainment".to_string(),
540 state: None,
541 attainment_date: None,
542 registration_date: None,
543 grade_scale_id: None,
544 grade_id: None,
545 }
546}
547
548fn attainment_summary(attainment: &MockAttainment) -> SuotarAttainment {
549 SuotarAttainment {
550 id: attainment.id.clone(),
551 attainment_type: attainment.attainment_type.clone(),
552 state: Some("ATTAINED".to_string()),
553 attainment_date: Some(attainment.attainment_date),
554 registration_date: Some(attainment.registration_date),
555 grade_scale_id: Some(attainment.grade_scale_id.clone()),
556 grade_id: Some(attainment.grade_id.clone()),
557 }
558}
559
560fn enrolments_for(
561 working: &WorkingSet,
562 student_number: &str,
563 course_code: &str,
564) -> Vec<MockEnrolment> {
565 working
566 .enrolments_by_person
567 .get(student_number)
568 .into_iter()
569 .flatten()
570 .filter_map(|id| working.enrolments.get(id))
571 .filter(|enrolment| enrolment.course_code == course_code)
572 .cloned()
573 .collect()
574}
575
576fn enrolment_dto(
577 course_unit: &MockCourseUnit,
578 enrolment: &MockEnrolment,
579 realisation: &MockRealisation,
580) -> wire::SuotarEnrolment {
581 wire::SuotarEnrolment {
582 id: enrolment.id.clone(),
583 state: serde_plain(&enrolment.state),
584 kind: realisation.kind.as_str().to_string(),
585 course_unit_id: course_unit.course_unit_id.clone(),
586 assessment_item_id: realisation.assessment_item_id.clone(),
587 course_unit_realisation_id: realisation.id.clone(),
588 course_unit_realisation_name: realisation.name.clone(),
589 activity_period: realisation.activity_period.clone(),
590 grade_scale_id: realisation.grade_scale_id.clone(),
591 credits: realisation.credits.clone(),
592 study_right_id: enrolment.study_right_id.clone(),
593 study_right_validity_period: enrolment.study_right_validity_period.clone(),
594 enrolment_date_time: enrolment.enrolment_date_time,
595 }
596}
597
598fn serde_plain<T: Serialize>(value: &T) -> String {
600 serde_json::to_value(value)
601 .ok()
602 .and_then(|value| value.as_str().map(str::to_string))
603 .unwrap_or_default()
604}
605
606#[cfg(test)]
607mod tests {
608 use chrono::Duration;
609
610 use super::super::ids;
611 use super::super::world::{
612 CreditRange, DatePeriod, LocalizedName, MockCourseUnit, MockPerson, MockRealisation,
613 PersonBehaviour, RealisationKind, WorldDefaults,
614 };
615 use super::*;
616
617 const STUDENT_NUMBER: &str = "900000101";
618 const COURSE_CODE: &str = "CRS-101";
619
620 fn world(ripeness: Ripeness) -> WorkingSet {
621 let now = Utc::now();
622 let period = DatePeriod {
623 start_date: (now - Duration::days(30)).date_naive(),
624 end_date: (now + Duration::days(30)).date_naive(),
625 };
626 let realisation = MockRealisation {
627 id: ids::realisation_id(COURSE_CODE, RealisationKind::Degree),
628 name: LocalizedName {
629 fi: COURSE_CODE.to_string(),
630 sv: COURSE_CODE.to_string(),
631 en: COURSE_CODE.to_string(),
632 },
633 assessment_item_id: ids::assessment_item_id(COURSE_CODE, RealisationKind::Degree),
634 kind: RealisationKind::Degree,
635 activity_period: period.clone(),
636 grade_scale_id: "sis-hyl-hyv".to_string(),
637 credits: CreditRange { min: 5.0, max: 5.0 },
638 acceptor_person_id: Some("hy-hlo-acceptor".to_string()),
639 open_university_product_id: None,
640 };
641 let enrolment = MockEnrolment {
642 id: ids::enrolment_id(STUDENT_NUMBER, RealisationKind::Degree),
643 student_number: STUDENT_NUMBER.to_string(),
644 course_code: COURSE_CODE.to_string(),
645 realisation_id: realisation.id.clone(),
646 state: EnrolmentState::Enrolled,
647 study_right_id: ids::study_right_id(STUDENT_NUMBER, RealisationKind::Degree),
648 study_right_validity_period: period.clone(),
649 enrolment_date_time: now,
650 };
651 WorkingSet {
652 defaults: WorldDefaults::default(),
653 persons: [(
654 STUDENT_NUMBER.to_string(),
655 MockPerson {
656 student_number: STUDENT_NUMBER.to_string(),
657 person_id: ids::person_id(STUDENT_NUMBER),
658 first_names: "Zzyzx".to_string(),
659 last_name: "Happypath".to_string(),
660 primary_email: "zzyzx.happypath@helsinki.example".to_string(),
661 secondary_email: None,
662 behaviour: PersonBehaviour {
663 ripeness: Some(ripeness),
664 duplicate_detection: None,
665 },
666 owner_user_email: None,
667 },
668 )]
669 .into(),
670 course_units: [(
671 COURSE_CODE.to_string(),
672 MockCourseUnit {
673 course_code: COURSE_CODE.to_string(),
674 course_unit_id: ids::course_unit_id(COURSE_CODE),
675 name: LocalizedName {
676 fi: COURSE_CODE.to_string(),
677 sv: COURSE_CODE.to_string(),
678 en: COURSE_CODE.to_string(),
679 },
680 realisations: vec![realisation],
681 behaviour: Default::default(),
682 owner_course_slug: None,
683 },
684 )]
685 .into(),
686 enrolments: [(enrolment.id.clone(), enrolment)].into(),
687 ..Default::default()
688 }
689 }
690
691 fn import(working: &mut WorkingSet) -> SuotarResponseItem<ImportAttainmentResult> {
692 let item = wire::ImportAttainmentRequestItem {
693 request_item_id: "cr-1".to_string(),
694 student_number: STUDENT_NUMBER.to_string(),
695 course_code: COURSE_CODE.to_string(),
696 enrolment_id: ids::enrolment_id(STUDENT_NUMBER, RealisationKind::Degree),
697 attainment_date: Utc::now().date_naive(),
698 attainment_language: "en".to_string(),
699 grade_scale_id: "sis-hyl-hyv".to_string(),
700 grade_id: "1".to_string(),
701 credits: 5.0,
702 };
703 import_item(&item, working, Utc::now())
704 }
705
706 fn verify(
707 working: &mut WorkingSet,
708 submitted_attainment_id: &str,
709 ) -> SuotarResponseItem<wire::VerifyAttainmentResult> {
710 let item = wire::VerifyAttainmentRequestItem {
711 request_item_id: "vf-1".to_string(),
712 submitted_attainment_id: submitted_attainment_id.to_string(),
713 };
714 verify_item(&item, working, Utc::now())
715 }
716
717 fn submitted_id(item: &SuotarResponseItem<ImportAttainmentResult>) -> String {
718 item.result
719 .as_ref()
720 .and_then(|result| result.submitted_attainment_id.clone())
721 .expect("a sent import answers with the submitted attainment id")
722 }
723
724 #[test]
727 fn an_import_queues_its_submission_before_any_response_shaping() {
728 let mut working = world(Ripeness::Manual);
729 let response = import(&mut working);
730 assert_eq!(response.code, "sent");
731 let id = submitted_id(&response);
732 assert!(working.submissions.contains_key(&id));
733 assert!(
734 working
735 .writes
736 .contains(&WorldWrite::UpsertSubmission(id.clone()))
737 );
738 assert!(working.writes.iter().any(|write| matches!(
739 write,
740 WorldWrite::IndexSubmission { id: indexed, .. } if indexed == &id
741 )));
742 }
743
744 #[test]
747 fn auto_ripening_counts_the_call_it_is_answering() {
748 let mut working = world(Ripeness::AutoAfterVerifyCalls { calls: 1 });
749 let id = submitted_id(&import(&mut working));
750 assert_eq!(verify(&mut working, &id).code, "notRegistered");
751 let second = verify(&mut working, &id);
752 assert_eq!(second.code, "registered");
753 let third = verify(&mut working, &id);
754 assert_eq!(second.result, third.result);
755 }
756
757 #[test]
759 fn a_manual_submission_never_ripens_on_its_own() {
760 let mut working = world(Ripeness::Manual);
761 let id = submitted_id(&import(&mut working));
762 for _ in 0..5 {
763 assert_eq!(verify(&mut working, &id).code, "notRegistered");
764 }
765 register(&mut working, &id, Utc::now());
766 assert_eq!(verify(&mut working, &id).code, "registered");
767 }
768
769 #[test]
770 fn an_at_import_submission_is_registered_by_the_import_that_created_it() {
771 let mut working = world(Ripeness::AtImport);
772 let response = import(&mut working);
773 assert_eq!(response.code, "registered");
774 }
775}