1use std::collections::HashSet;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use async_trait::async_trait;
12use chrono::{DateTime, NaiveDate};
13#[cfg(any(test, feature = "test-support"))]
14use headless_lms_base::config::MOCK_SUOTAR_TOKEN;
15use headless_lms_base::config::{
16 SUOTAR_AUTH_SCHEME, SuotarConfiguration, bool_env_false_by_default,
17};
18use once_cell::sync::Lazy;
19use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
20use secrecy::{ExposeSecret, SecretString};
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Deserializer};
23use utoipa::ToSchema;
24
25use crate::{prelude::*, secret_string::serialize_exposed};
26
27pub const INTERACTIVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(50);
29
30static SUOTAR_HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
33 suotar_client_builder()
34 .build()
35 .expect("Failed to build the Suotar client: safe to crash, it is built at startup")
36});
37
38#[cfg(any(test, feature = "test-support"))]
41static SUOTAR_PLAIN_HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
42 suotar_client_builder()
43 .https_only(false)
44 .build()
45 .expect("Failed to build the plain-http Suotar client")
46});
47
48fn suotar_client_builder() -> reqwest::ClientBuilder {
49 crate::http::base_client_builder().tcp_keepalive(Duration::from_secs(30))
50}
51
52pub const CORRELATION_ID_HEADER: &str = "X-Correlation-Id";
54
55pub const MAX_REQUEST_BODY_BYTES: usize = 5 * 1024 * 1024;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type, ToSchema)]
60#[sqlx(type_name = "suotar_endpoint", rename_all = "snake_case")]
61#[serde(rename_all = "snake_case")]
62pub enum SuotarEndpoint {
63 ResolvePersons,
64 ResolveEnrolments,
65 ImportAttainments,
66 VerifyAttainments,
67 ListByCourse,
68 ValidateCourseCodes,
69}
70
71impl SuotarEndpoint {
72 pub fn path(self) -> &'static str {
74 match self {
75 Self::ResolvePersons => "persons/resolve-by-student-numbers",
76 Self::ResolveEnrolments => "enrolments/resolve",
77 Self::ImportAttainments => "attainments/import",
78 Self::VerifyAttainments => "attainments/verify",
79 Self::ListByCourse => "enrolments/list-by-course",
80 Self::ValidateCourseCodes => "course-codes/validate",
81 }
82 }
83
84 pub fn max_batch_size(self) -> usize {
86 match self {
87 Self::ResolvePersons
88 | Self::ResolveEnrolments
89 | Self::VerifyAttainments
90 | Self::ValidateCourseCodes => 1000,
91 Self::ImportAttainments => 100,
92 Self::ListByCourse => 50,
93 }
94 }
95
96 pub const fn request_timeout(self) -> Duration {
100 let minutes = match self {
101 Self::ImportAttainments => 60,
102 Self::VerifyAttainments => 25,
103 Self::ResolveEnrolments | Self::ListByCourse => 20,
104 Self::ResolvePersons | Self::ValidateCourseCodes => 10,
105 };
106 Duration::from_secs(minutes * 60)
107 }
108
109 pub fn creates_attainments(self) -> bool {
112 matches!(self, Self::ImportAttainments)
113 }
114}
115
116pub trait BatchEndpoint {
119 type Item: SuotarRequestItem;
120 type Result: DeserializeOwned;
121 const ENDPOINT: SuotarEndpoint;
122}
123
124pub mod endpoints {
126 use super::*;
127
128 macro_rules! batch_endpoint {
129 ($name:ident, $item:ty, $result:ty) => {
130 pub struct $name;
131
132 impl BatchEndpoint for $name {
133 type Item = $item;
134 type Result = $result;
135 const ENDPOINT: SuotarEndpoint = SuotarEndpoint::$name;
136 }
137 };
138 }
139
140 batch_endpoint!(ResolvePersons, ResolvePersonRequestItem, PersonResult);
141 batch_endpoint!(
142 ResolveEnrolments,
143 ResolveEnrolmentRequestItem,
144 EnrolmentResolutionResult
145 );
146 batch_endpoint!(
147 ImportAttainments,
148 ImportAttainmentRequestItem,
149 ImportAttainmentResult
150 );
151 batch_endpoint!(
152 VerifyAttainments,
153 VerifyAttainmentRequestItem,
154 VerifyAttainmentResult
155 );
156 batch_endpoint!(
157 ListByCourse,
158 ListByCourseRequestItem,
159 EnrolmentsListedResult
160 );
161 batch_endpoint!(
162 ValidateCourseCodes,
163 ValidateCourseCodeRequestItem,
164 ValidateCourseCodeResult
165 );
166}
167
168pub fn new_request_item_id() -> String {
170 Uuid::new_v4().to_string()
171}
172
173pub trait SuotarRequestItem: Serialize {
176 fn request_item_id(&self) -> &str;
177 fn student_number(&self) -> Option<&SecretString> {
179 None
180 }
181}
182
183macro_rules! request_item {
184 ($name:ident $(, $student_number:ident)?) => {
185 impl SuotarRequestItem for $name {
186 fn request_item_id(&self) -> &str {
187 &self.request_item_id
188 }
189
190 $(
191 fn student_number(&self) -> Option<&SecretString> {
192 Some(&self.$student_number)
193 }
194 )?
195 }
196 };
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct ResolvePersonRequestItem {
202 pub request_item_id: String,
203 #[serde(serialize_with = "serialize_exposed")]
204 pub student_number: SecretString,
205}
206request_item!(ResolvePersonRequestItem, student_number);
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ResolveEnrolmentRequestItem {
211 pub request_item_id: String,
212 #[serde(serialize_with = "serialize_exposed")]
213 pub student_number: SecretString,
214 pub course_code: String,
215}
216request_item!(ResolveEnrolmentRequestItem, student_number);
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct ImportAttainmentRequestItem {
221 pub request_item_id: String,
222 #[serde(serialize_with = "serialize_exposed")]
223 pub student_number: SecretString,
224 pub course_code: String,
225 pub enrolment_id: String,
226 pub attainment_date: DateTime<Utc>,
228 pub attainment_language: String,
229 pub grade_scale_id: String,
230 pub grade_id: String,
231 pub credits: f64,
232}
233request_item!(ImportAttainmentRequestItem, student_number);
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct VerifyAttainmentRequestItem {
238 pub request_item_id: String,
239 pub submitted_attainment_id: String,
240}
241request_item!(VerifyAttainmentRequestItem);
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct ListByCourseRequestItem {
247 pub request_item_id: String,
248 pub course_code: String,
249}
250request_item!(ListByCourseRequestItem);
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct ValidateCourseCodeRequestItem {
255 pub request_item_id: String,
256 pub course_code: String,
257}
258request_item!(ValidateCourseCodeRequestItem);
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct LocalizedName {
263 pub fi: Option<String>,
264 pub sv: Option<String>,
265 pub en: Option<String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct DatePeriod {
272 #[serde(default, deserialize_with = "lenient_date")]
273 pub start_date: Option<NaiveDate>,
274 #[serde(default, deserialize_with = "lenient_date")]
275 pub end_date: Option<NaiveDate>,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct CreditRange {
282 #[serde(default, deserialize_with = "lenient")]
283 pub min: Option<f64>,
284 #[serde(default, deserialize_with = "lenient")]
285 pub max: Option<f64>,
286}
287
288#[derive(Debug, Clone, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct PersonResult {
291 pub student_number: SecretString,
292 pub person_id: SecretString,
293 pub first_names: Option<SecretString>,
295 pub last_name: Option<SecretString>,
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct SuotarEnrolment {
303 pub id: String,
304 #[serde(default, deserialize_with = "lenient")]
305 pub state: Option<String>,
306 #[serde(default, deserialize_with = "lenient")]
307 pub kind: Option<String>,
308 #[serde(default, deserialize_with = "lenient")]
309 pub course_unit_realisation_id: Option<String>,
310 #[serde(default, deserialize_with = "lenient")]
311 pub course_unit_realisation_name: Option<LocalizedName>,
312 #[serde(default, deserialize_with = "lenient")]
313 pub activity_period: Option<DatePeriod>,
314 #[serde(default, deserialize_with = "lenient_id")]
316 pub grade_scale_id: Option<String>,
317 #[serde(default, deserialize_with = "lenient")]
319 pub credits: Option<CreditRange>,
320 #[serde(default, deserialize_with = "lenient")]
322 pub study_right_validity_period: Option<DatePeriod>,
323 #[serde(default, deserialize_with = "lenient_instant")]
324 pub enrolment_date_time: Option<DateTime<Utc>>,
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct EnrolmentResolutionResult {
333 #[serde(default, deserialize_with = "readable_elements")]
334 pub enrolments: Vec<SuotarEnrolment>,
335 #[serde(default, deserialize_with = "readable_elements")]
336 pub existing_attainments: Vec<SuotarAttainment>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SuotarAttainment {
347 pub id: String,
348 #[serde(rename = "type")]
349 pub attainment_type: String,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 pub state: Option<String>,
352 #[serde(
353 default,
354 deserialize_with = "lenient_date",
355 skip_serializing_if = "Option::is_none"
356 )]
357 pub attainment_date: Option<NaiveDate>,
358 #[serde(
359 default,
360 deserialize_with = "lenient_date",
361 skip_serializing_if = "Option::is_none"
362 )]
363 pub registration_date: Option<NaiveDate>,
364 #[serde(
365 default,
366 deserialize_with = "lenient_id",
367 skip_serializing_if = "Option::is_none"
368 )]
369 pub grade_scale_id: Option<String>,
370 #[serde(
371 default,
372 deserialize_with = "lenient_id",
373 skip_serializing_if = "Option::is_none"
374 )]
375 pub grade_id: Option<String>,
376}
377
378#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct ImportAttainmentResult {
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub submitted_attainment_id: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub submitted_attainment_type: Option<String>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 pub attainment: Option<SuotarAttainment>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 pub previous_attainment: Option<SuotarAttainment>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase")]
397pub struct VerifyAttainmentResult {
398 #[serde(skip_serializing_if = "Option::is_none")]
399 pub attainment: Option<SuotarAttainment>,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub submitted_attainment_id: Option<String>,
402 #[serde(skip_serializing_if = "Option::is_none")]
403 pub submitted_attainment_type: Option<String>,
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub retry_after: Option<DateTime<Utc>>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct ValidateCourseCodeResult {
412 pub course_code: String,
413 pub name: Option<String>,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "camelCase")]
420pub struct ListedEnrolment {
421 #[serde(default, deserialize_with = "lenient")]
422 pub id: Option<String>,
423 #[serde(default, deserialize_with = "lenient")]
424 pub course_unit_realisation_id: Option<String>,
425 #[serde(default, deserialize_with = "lenient")]
426 pub state: Option<String>,
427 #[serde(default, deserialize_with = "lenient_instant")]
428 pub enrolment_date_time: Option<DateTime<Utc>>,
429}
430
431#[derive(Debug, Clone, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct ListedPerson {
434 pub student_number: SecretString,
435 pub person_id: SecretString,
436 pub first_names: Option<SecretString>,
437 pub last_name: Option<SecretString>,
438 pub primary_email: Option<SecretString>,
439 pub secondary_email: Option<SecretString>,
440 #[serde(default, deserialize_with = "lenient")]
441 pub enrolment: Option<ListedEnrolment>,
442}
443
444#[derive(Debug, Clone, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct EnrolmentsListedResult {
447 #[serde(deserialize_with = "readable_elements")]
449 pub people: Vec<ListedPerson>,
450}
451
452fn lenient_date<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<NaiveDate>, D::Error> {
455 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
456 Ok(value
457 .as_ref()
458 .and_then(serde_json::Value::as_str)
459 .and_then(|text| {
460 NaiveDate::parse_from_str(text, "%Y-%m-%d")
461 .ok()
462 .or_else(|| {
463 DateTime::parse_from_rfc3339(text)
464 .ok()
465 .map(|instant| instant.with_timezone(&Utc).date_naive())
466 })
467 }))
468}
469
470fn lenient<'de, D: Deserializer<'de>, T: DeserializeOwned>(
472 deserializer: D,
473) -> Result<Option<T>, D::Error> {
474 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
475 Ok(value.and_then(|value| serde_json::from_value(value).ok()))
476}
477
478fn lenient_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
483 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
484 Ok(value.and_then(|value| match value {
485 serde_json::Value::String(text) => Some(text),
486 serde_json::Value::Number(number) => Some(number.to_string()),
487 _ => None,
488 }))
489}
490
491fn lenient_instant<'de, D: Deserializer<'de>>(
494 deserializer: D,
495) -> Result<Option<DateTime<Utc>>, D::Error> {
496 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
497 Ok(value
498 .as_ref()
499 .and_then(serde_json::Value::as_str)
500 .and_then(|text| {
501 DateTime::parse_from_rfc3339(text)
502 .map(|instant| instant.with_timezone(&Utc))
503 .ok()
504 .or_else(|| {
505 chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f")
506 .ok()
507 .map(|local| local.and_utc())
508 })
509 }))
510}
511
512fn readable_elements<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
514where
515 D: Deserializer<'de>,
516 T: DeserializeOwned,
517{
518 let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
519 let total = values.len();
520 let readable: Vec<T> = values
521 .into_iter()
522 .filter_map(|value| serde_json::from_value(value).ok())
523 .collect();
524 if readable.len() < total {
525 warn!(
526 unreadable = total - readable.len(),
527 total, "Suotar answered with list elements that could not be read; skipping them"
528 );
529 }
530 Ok(readable)
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub enum SuotarItemStatus {
536 Ok,
537 Error,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct SuotarItemError {
543 pub message: String,
544}
545
546#[derive(Debug, Clone, PartialEq, Serialize)]
547#[serde(rename_all = "camelCase")]
548pub struct SuotarResponseItem<R> {
549 pub request_item_id: String,
550 pub status: SuotarItemStatus,
551 pub code: String,
554 #[serde(skip_serializing_if = "Option::is_none")]
558 pub result: Option<R>,
559 #[serde(skip_serializing_if = "Option::is_none")]
560 pub error: Option<SuotarItemError>,
561}
562
563impl<'de, R: DeserializeOwned> Deserialize<'de> for SuotarResponseItem<R> {
564 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
565 #[derive(Deserialize)]
566 #[serde(rename_all = "camelCase")]
567 struct Wire {
568 request_item_id: String,
569 status: SuotarItemStatus,
570 code: String,
571 #[serde(default)]
572 result: Option<serde_json::Value>,
573 #[serde(default)]
574 error: Option<SuotarItemError>,
575 }
576
577 let wire = Wire::deserialize(deserializer)?;
578 let result = match wire.result.map(serde_json::from_value::<R>) {
579 None => None,
580 Some(Ok(result)) => Some(result),
581 Some(Err(_)) if wire.status == SuotarItemStatus::Error => {
583 warn!(
584 code = %wire.code,
585 "Suotar answered with a result that could not be read; ignoring the result"
586 );
587 None
588 }
589 Some(Err(_)) => {
591 return Err(serde::de::Error::custom(format!(
592 "the result of a `{}` item could not be read",
593 wire.code
594 )));
595 }
596 };
597 Ok(Self {
598 request_item_id: wire.request_item_id,
599 status: wire.status,
600 code: wire.code,
601 result,
602 error: wire.error,
603 })
604 }
605}
606
607#[derive(Debug)]
608pub struct SuotarBatchResponse<R> {
609 pub endpoint: SuotarEndpoint,
610 pub items: Vec<SuotarResponseItem<R>>,
611 pub duration: Duration,
612 pub call_id: Option<Uuid>,
614 pub raw_response: Arc<serde_json::Value>,
617}
618
619impl<R> SuotarBatchResponse<R> {
620 pub fn item(&self, request_item_id: &str) -> Option<&SuotarResponseItem<R>> {
621 self.items
622 .iter()
623 .find(|item| item.request_item_id == request_item_id)
624 }
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum SuotarErrorVariant {
631 Unauthorized,
633 MalformedRequest,
635 RequestLevelError,
637 ServiceTemporarilyUnavailable,
640 ServerError,
641 TransportNotDelivered,
643 TransportUnknown,
645 Deserialization,
647}
648
649#[derive(Debug)]
652pub struct SuotarError {
653 pub variant: SuotarErrorVariant,
654 pub was_sent: bool,
656 error: UtilError,
657}
658
659impl SuotarError {
660 #[track_caller]
661 fn new(variant: SuotarErrorVariant, message: impl Into<String>) -> Self {
662 Self {
663 variant,
664 was_sent: true,
665 error: util_err!(SuotarClientError, message.into()),
666 }
667 }
668
669 #[track_caller]
670 fn caused_by(
671 variant: SuotarErrorVariant,
672 message: impl Into<String>,
673 source: impl Into<anyhow::Error>,
674 ) -> Self {
675 Self {
676 variant,
677 was_sent: true,
678 error: util_err!(SuotarClientError, message.into(), source.into()),
679 }
680 }
681
682 fn unsent(self) -> Self {
683 Self {
684 was_sent: false,
685 ..self
686 }
687 }
688
689 pub fn message(&self) -> &str {
691 self.error.message()
692 }
693}
694
695impl std::fmt::Display for SuotarError {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 write!(f, "{:?}: {}", self.variant, self.message())
698 }
699}
700
701impl std::error::Error for SuotarError {
702 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
703 Some(&self.error)
704 }
705}
706
707#[derive(Debug, Clone, Default)]
710pub struct SuotarCallContext {
711 pub worker_name: String,
712 pub credit_registration_ids: Vec<Uuid>,
713 pub request_timeout: Option<Duration>,
716}
717
718#[derive(Debug, Clone)]
719pub struct SuotarCallStarted {
720 pub endpoint: SuotarEndpoint,
721 pub request_item_count: usize,
722 pub worker_name: String,
723 pub credit_registration_ids: Vec<Uuid>,
724 pub request_item_ids: Vec<String>,
725 pub started_at: DateTime<Utc>,
726 pub request_body: serde_json::Value,
728}
729
730#[derive(Debug, Clone, Default)]
731pub struct SuotarCallFinished {
732 pub http_status: Option<u16>,
733 pub duration: Duration,
734 pub succeeded: bool,
735 pub ok_item_count: usize,
736 pub error_item_count: usize,
737 pub error_item_codes: Vec<String>,
739 pub endpoint: Option<SuotarEndpoint>,
741 pub request_level_error_code: Option<String>,
742 pub error_message: Option<String>,
743 pub response_body: Option<Arc<serde_json::Value>>,
745}
746
747#[async_trait]
750pub trait SuotarCallAudit: Send + Sync {
751 async fn started(&self, started: SuotarCallStarted) -> Option<Uuid>;
754
755 async fn finished(&self, call_id: Uuid, finished: SuotarCallFinished);
756}
757
758pub struct NoSuotarCallAudit;
759
760#[async_trait]
761impl SuotarCallAudit for NoSuotarCallAudit {
762 async fn started(&self, _started: SuotarCallStarted) -> Option<Uuid> {
763 None
764 }
765
766 async fn finished(&self, _call_id: Uuid, _finished: SuotarCallFinished) {}
767}
768
769pub const REFUSED_BEFORE_SENDING_CODE: &str = "refusedBeforeSending";
772
773fn authorization_header_value(token: &str) -> String {
775 format!("{SUOTAR_AUTH_SCHEME} {token}")
776}
777
778#[derive(Clone)]
779pub struct SuotarClient {
780 api_base_url: Url,
781 authorization: SecretString,
782 audit: Arc<dyn SuotarCallAudit>,
783 allow_http: bool,
786}
787
788impl SuotarClient {
789 pub fn new(config: &SuotarConfiguration, audit: Arc<dyn SuotarCallAudit>) -> Self {
792 Lazy::force(&SUOTAR_HTTP_CLIENT);
793 Self {
794 api_base_url: config.api_base_url.clone(),
795 authorization: SecretString::new(
796 authorization_header_value(config.api_token.expose_secret()).into(),
797 ),
798 audit,
799 allow_http: bool_env_false_by_default("TEST_MODE"),
800 }
801 }
802
803 #[cfg(any(test, feature = "test-support"))]
806 pub fn new_allowing_http(
807 config: &SuotarConfiguration,
808 audit: Arc<dyn SuotarCallAudit>,
809 ) -> Self {
810 Self {
811 allow_http: true,
812 ..Self::new(config, audit)
813 }
814 }
815
816 #[cfg(any(test, feature = "test-support"))]
818 pub fn mock_for_test() -> Self {
819 Self {
820 api_base_url: Url::parse("http://project-331.local/api/v0/mock-suotar/")
821 .expect("hardcoded url"),
822 authorization: SecretString::new(authorization_header_value(MOCK_SUOTAR_TOKEN).into()),
823 audit: Arc::new(NoSuotarCallAudit),
824 allow_http: bool_env_false_by_default("TEST_MODE"),
825 }
826 }
827
828 pub async fn post<E: BatchEndpoint>(
830 &self,
831 context: SuotarCallContext,
832 items: Vec<E::Item>,
833 ) -> Result<SuotarBatchResponse<E::Result>, SuotarError> {
834 let endpoint = E::ENDPOINT;
835 if items.is_empty() {
836 return Ok(empty_batch_response(endpoint));
837 }
838 let not_encoded = |error| {
840 SuotarError::caused_by(
841 SuotarErrorVariant::TransportNotDelivered,
842 format!("Could not encode a {} request", endpoint.path()),
843 error,
844 )
845 .unsent()
846 };
847 let request_body = serde_json::to_value(&items).map_err(not_encoded)?;
848 let encoded = serde_json::to_vec(&request_body).map_err(not_encoded)?;
849 let call_id = self
851 .audit
852 .started(SuotarCallStarted {
853 endpoint,
854 request_item_count: items.len(),
855 worker_name: context.worker_name,
856 credit_registration_ids: context.credit_registration_ids,
857 request_item_ids: items
858 .iter()
859 .map(|item| item.request_item_id().to_string())
860 .collect(),
861 started_at: Utc::now(),
862 request_body,
863 })
864 .await;
865 if call_id.is_none() {
866 error!(
867 endpoint = endpoint.path(),
868 "Could not write a suotar_api_calls row; sending the call unaudited"
869 );
870 }
871
872 let sent_ids = match check_batch(endpoint, &items) {
873 Ok(sent_ids) => sent_ids,
874 Err(error) => return self.refused(call_id, error).await,
875 };
876 if encoded.len() > MAX_REQUEST_BODY_BYTES {
877 return self
878 .refused(
879 call_id,
880 SuotarError::new(SuotarErrorVariant::MalformedRequest, format!(
881 "A {} request of {} items encodes to {} bytes, over the {MAX_REQUEST_BODY_BYTES} byte limit.",
882 endpoint.path(),
883 sent_ids.len(),
884 encoded.len()
885 )),
886 )
887 .await;
888 }
889
890 let url = match self.api_base_url.join(endpoint.path()) {
891 Ok(url) => url,
892 Err(error) => {
893 return self
894 .refused(
895 call_id,
896 SuotarError::caused_by(
897 SuotarErrorVariant::TransportNotDelivered,
898 format!("Could not build the Suotar {} url", endpoint.path()),
899 error,
900 ),
901 )
902 .await;
903 }
904 };
905 if !self.allow_http && url.scheme() != "https" {
906 return self
907 .refused(
908 call_id,
909 SuotarError::new(
910 SuotarErrorVariant::TransportNotDelivered,
911 format!(
912 "Refusing to send a {} request over plain http",
913 endpoint.path()
914 ),
915 ),
916 )
917 .await;
918 }
919 let clock = Instant::now();
920 #[cfg(any(test, feature = "test-support"))]
921 let http_client = if self.allow_http {
922 &*SUOTAR_PLAIN_HTTP_CLIENT
923 } else {
924 &*SUOTAR_HTTP_CLIENT
925 };
926 #[cfg(not(any(test, feature = "test-support")))]
928 let http_client = &*SUOTAR_HTTP_CLIENT;
929 let mut request = http_client
930 .post(url)
931 .timeout(
932 context
933 .request_timeout
934 .unwrap_or_else(|| endpoint.request_timeout()),
935 )
936 .header(AUTHORIZATION, self.authorization.expose_secret())
937 .header(CONTENT_TYPE, "application/json");
938 if let Some(call_id) = call_id {
939 request = request.header(CORRELATION_ID_HEADER, call_id.to_string());
940 }
941
942 let (mut outcome, finished) = self
943 .exchange(endpoint, request, encoded, sent_ids, clock)
944 .await;
945 if let Ok(response) = &mut outcome {
946 response.call_id = call_id;
947 }
948 if let Some(call_id) = call_id {
949 self.audit.finished(call_id, finished).await;
950 }
951 outcome
952 }
953
954 async fn refused<R>(
957 &self,
958 call_id: Option<Uuid>,
959 error: SuotarError,
960 ) -> Result<SuotarBatchResponse<R>, SuotarError> {
961 if let Some(call_id) = call_id {
962 self.audit
963 .finished(
964 call_id,
965 SuotarCallFinished {
966 request_level_error_code: Some(REFUSED_BEFORE_SENDING_CODE.to_string()),
967 error_message: Some(error.message().to_string()),
968 ..SuotarCallFinished::default()
969 },
970 )
971 .await;
972 }
973 Err(error.unsent())
974 }
975
976 async fn exchange<R: DeserializeOwned>(
979 &self,
980 endpoint: SuotarEndpoint,
981 request: reqwest::RequestBuilder,
982 body: Vec<u8>,
983 sent_ids: Vec<String>,
984 clock: Instant,
985 ) -> Exchanged<R> {
986 let response = match request.body(body).send().await {
987 Ok(response) => response,
988 Err(error) => {
989 return failed(
990 SuotarError::caused_by(
991 transport_variant(&error),
992 format!("Request to Suotar {} failed", endpoint.path()),
993 error,
994 ),
995 None,
996 clock.elapsed(),
997 None,
998 None,
999 );
1000 }
1001 };
1002
1003 let http_status = response.status().as_u16();
1004 let text = match response.text().await {
1005 Ok(text) => text,
1006 Err(error) => {
1007 return failed(
1008 SuotarError::caused_by(
1009 transport_variant(&error),
1010 format!(
1011 "Reading the Suotar {} response body failed",
1012 endpoint.path()
1013 ),
1014 error,
1015 ),
1016 Some(http_status),
1017 clock.elapsed(),
1018 None,
1019 None,
1020 );
1021 }
1022 };
1023 let duration = clock.elapsed();
1024
1025 if !(200..300).contains(&http_status) {
1026 let detail = serde_json::from_str::<RequestLevelErrorBody>(&text)
1027 .ok()
1028 .map(|parsed| parsed.error);
1029 let code = detail
1030 .as_ref()
1031 .and_then(RequestLevelErrorDetail::code)
1032 .map(str::to_string);
1033 let error = request_level_error(endpoint, http_status, detail.as_ref());
1034 return failed(
1035 error,
1036 Some(http_status),
1037 duration,
1038 code,
1039 Some(Arc::new(body_for_audit(&text))),
1040 );
1041 }
1042
1043 let raw_response: Arc<serde_json::Value> = match serde_json::from_str(&text) {
1044 Ok(value) => Arc::new(value),
1045 Err(error) => {
1046 return failed(
1047 SuotarError::caused_by(
1048 SuotarErrorVariant::Deserialization,
1049 format!(
1050 "Suotar {} answered {http_status} with a body that is not JSON",
1051 endpoint.path()
1052 ),
1053 error,
1054 ),
1055 Some(http_status),
1056 duration,
1057 None,
1058 Some(Arc::new(body_for_audit(&text))),
1059 );
1060 }
1061 };
1062 let Some(array) = raw_response.as_array() else {
1063 return failed(
1064 SuotarError::new(
1065 SuotarErrorVariant::Deserialization,
1066 format!(
1067 "Suotar {} answered {http_status} with a body that is not a batch response",
1068 endpoint.path()
1069 ),
1070 ),
1071 Some(http_status),
1072 duration,
1073 None,
1074 Some(raw_response),
1075 );
1076 };
1077 let items: Vec<SuotarResponseItem<R>> = array
1081 .iter()
1082 .filter_map(|item| match SuotarResponseItem::<R>::deserialize(item) {
1083 Ok(parsed) => Some(parsed),
1084 Err(_) => {
1086 error!(
1087 endpoint = endpoint.path(),
1088 "Suotar answered with an item that could not be read; treating it as unanswered"
1089 );
1090 None
1091 }
1092 })
1093 .collect();
1094
1095 let response = reconcile(endpoint, sent_ids, items, duration, raw_response);
1096 let finished = SuotarCallFinished {
1097 http_status: Some(http_status),
1098 duration,
1099 succeeded: true,
1100 ok_item_count: response
1101 .items
1102 .iter()
1103 .filter(|item| item.status == SuotarItemStatus::Ok)
1104 .count(),
1105 error_item_count: response
1106 .items
1107 .iter()
1108 .filter(|item| item.status == SuotarItemStatus::Error)
1109 .count(),
1110 error_item_codes: response
1111 .items
1112 .iter()
1113 .filter(|item| item.status == SuotarItemStatus::Error)
1114 .map(|item| item.code.clone())
1115 .collect(),
1116 endpoint: Some(endpoint),
1117 request_level_error_code: None,
1118 error_message: None,
1119 response_body: Some(Arc::clone(&response.raw_response)),
1120 };
1121 (Ok(response), finished)
1122 }
1123}
1124
1125type Exchanged<R> = (
1126 Result<SuotarBatchResponse<R>, SuotarError>,
1127 SuotarCallFinished,
1128);
1129
1130fn failed<R>(
1131 error: SuotarError,
1132 http_status: Option<u16>,
1133 duration: Duration,
1134 request_level_error_code: Option<String>,
1135 response_body: Option<Arc<serde_json::Value>>,
1136) -> Exchanged<R> {
1137 let finished = SuotarCallFinished {
1138 http_status,
1139 duration,
1140 succeeded: false,
1141 ok_item_count: 0,
1142 error_item_count: 0,
1143 error_item_codes: Vec::new(),
1144 endpoint: None,
1145 request_level_error_code,
1146 error_message: Some(error.message().to_string()),
1147 response_body,
1148 };
1149 (Err(error), finished)
1150}
1151
1152fn body_for_audit(text: &str) -> serde_json::Value {
1154 serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
1155}
1156
1157fn check_batch<T: SuotarRequestItem>(
1160 endpoint: SuotarEndpoint,
1161 items: &[T],
1162) -> Result<Vec<String>, SuotarError> {
1163 if items.len() > endpoint.max_batch_size() {
1164 return Err(SuotarError::new(
1165 SuotarErrorVariant::MalformedRequest,
1166 format!(
1167 "A {} request carries {} items, over the batch size of {}.",
1168 endpoint.path(),
1169 items.len(),
1170 endpoint.max_batch_size()
1171 ),
1172 ));
1173 }
1174 let mut seen = HashSet::with_capacity(items.len());
1175 for item in items {
1176 if !seen.insert(item.request_item_id()) {
1177 return Err(SuotarError::new(
1178 SuotarErrorVariant::MalformedRequest,
1179 format!(
1180 "A {} request repeats requestItemId `{}`.",
1181 endpoint.path(),
1182 item.request_item_id()
1183 ),
1184 ));
1185 }
1186 }
1187 Ok(items
1188 .iter()
1189 .map(|item| item.request_item_id().to_string())
1190 .collect())
1191}
1192
1193fn empty_batch_response<R>(endpoint: SuotarEndpoint) -> SuotarBatchResponse<R> {
1195 SuotarBatchResponse {
1196 endpoint,
1197 items: Vec::new(),
1198 duration: Duration::ZERO,
1199 call_id: None,
1200 raw_response: Arc::new(serde_json::Value::Array(Vec::new())),
1201 }
1202}
1203
1204#[derive(Debug, PartialEq)]
1206struct UnpairedItemIds {
1207 missing: Vec<String>,
1209 unexpected: Vec<String>,
1211}
1212
1213fn unpaired_item_ids<R>(sent_ids: &[String], items: &[SuotarResponseItem<R>]) -> UnpairedItemIds {
1214 let sent: HashSet<&str> = sent_ids.iter().map(String::as_str).collect();
1215 let answered: HashSet<&str> = items
1216 .iter()
1217 .map(|item| item.request_item_id.as_str())
1218 .collect();
1219 UnpairedItemIds {
1220 missing: sent_ids
1221 .iter()
1222 .filter(|id| !answered.contains(id.as_str()))
1223 .cloned()
1224 .collect(),
1225 unexpected: items
1226 .iter()
1227 .filter(|item| !sent.contains(item.request_item_id.as_str()))
1228 .map(|item| item.request_item_id.clone())
1229 .collect(),
1230 }
1231}
1232
1233fn reconcile<R>(
1235 endpoint: SuotarEndpoint,
1236 sent_ids: Vec<String>,
1237 items: Vec<SuotarResponseItem<R>>,
1238 duration: Duration,
1239 raw_response: Arc<serde_json::Value>,
1240) -> SuotarBatchResponse<R> {
1241 let unpaired = unpaired_item_ids(&sent_ids, &items);
1242 if !unpaired.unexpected.is_empty() {
1243 warn!(
1244 endpoint = endpoint.path(),
1245 unexpected = unpaired.unexpected.len(),
1246 "Suotar answered with requestItemIds that were not sent; ignoring them"
1247 );
1248 }
1249 if !unpaired.missing.is_empty() && endpoint.creates_attainments() {
1250 error!(
1251 endpoint = endpoint.path(),
1252 missing = unpaired.missing.len(),
1253 sent = sent_ids.len(),
1254 "Suotar left items unanswered; their attainments may or may not exist and must not be re-sent"
1255 );
1256 }
1257
1258 SuotarBatchResponse {
1259 endpoint,
1260 items,
1261 duration,
1262 call_id: None,
1263 raw_response,
1264 }
1265}
1266
1267#[derive(Debug, Deserialize)]
1268struct RequestLevelErrorBody {
1269 error: RequestLevelErrorDetail,
1270}
1271
1272#[derive(Debug, Deserialize)]
1274#[serde(untagged)]
1275enum RequestLevelErrorDetail {
1276 Coded { code: String, message: String },
1277 Bare(String),
1278}
1279
1280impl RequestLevelErrorDetail {
1281 fn code(&self) -> Option<&str> {
1282 match self {
1283 Self::Coded { code, .. } => Some(code),
1284 Self::Bare(_) => None,
1285 }
1286 }
1287}
1288
1289fn request_level_error(
1290 endpoint: SuotarEndpoint,
1291 http_status: u16,
1292 detail: Option<&RequestLevelErrorDetail>,
1293) -> SuotarError {
1294 let path = endpoint.path();
1295 if let Some(RequestLevelErrorDetail::Bare(message)) = detail
1298 && http_status == 401
1299 {
1300 return SuotarError::new(
1301 SuotarErrorVariant::RequestLevelError,
1302 format!(
1303 "Suotar answered {path} with 401 `{message}`, which means the moocfi API does not serve that path. Check SUOTAR_API_BASE_URL."
1304 ),
1305 );
1306 }
1307 let variant = match (http_status, detail.and_then(RequestLevelErrorDetail::code)) {
1308 (401 | 403, _) | (_, Some("unauthorized")) => SuotarErrorVariant::Unauthorized,
1309 (413, _) | (_, Some("malformedRequest" | "requestTooLarge")) => {
1310 SuotarErrorVariant::MalformedRequest
1311 }
1312 (503, Some("serviceTemporarilyUnavailable")) => {
1313 SuotarErrorVariant::ServiceTemporarilyUnavailable
1314 }
1315 (500..=599, _) => SuotarErrorVariant::ServerError,
1316 _ => SuotarErrorVariant::RequestLevelError,
1317 };
1318 let detail = match detail {
1319 Some(RequestLevelErrorDetail::Coded { code, message }) => format!("`{code}`: {message}"),
1320 Some(RequestLevelErrorDetail::Bare(message)) => format!("`{message}`"),
1321 None => "no documented error body".to_string(),
1322 };
1323 SuotarError::new(
1324 variant,
1325 format!("Suotar {path} rejected the whole request with {http_status}, {detail}"),
1326 )
1327}
1328
1329fn transport_variant(error: &reqwest::Error) -> SuotarErrorVariant {
1332 if error.is_connect() || error.is_builder() {
1333 SuotarErrorVariant::TransportNotDelivered
1334 } else {
1335 SuotarErrorVariant::TransportUnknown
1336 }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::*;
1342 use serde_json::json;
1343
1344 fn person_items(ids: &[&str]) -> Vec<ResolvePersonRequestItem> {
1345 ids.iter()
1346 .map(|id| ResolvePersonRequestItem {
1347 request_item_id: (*id).to_string(),
1348 student_number: "012345678".into(),
1349 })
1350 .collect()
1351 }
1352
1353 fn person_response(ids: &[&str]) -> Vec<SuotarResponseItem<PersonResult>> {
1354 let items: Vec<serde_json::Value> = ids
1355 .iter()
1356 .map(|id| {
1357 json!({
1358 "requestItemId": id,
1359 "status": "ok",
1360 "code": "personFound",
1361 "result": {
1362 "studentNumber": "012345678",
1363 "personId": "otm-person-id",
1364 "firstNames": "Henrik Admin",
1365 "lastName": "Nygren",
1366 }
1367 })
1368 })
1369 .collect();
1370 serde_json::from_value(json!(items)).expect("person response")
1371 }
1372
1373 fn classified(http_status: u16, body: &str) -> SuotarError {
1374 let detail = serde_json::from_str::<RequestLevelErrorBody>(body)
1375 .ok()
1376 .map(|parsed| parsed.error);
1377 request_level_error(
1378 SuotarEndpoint::ImportAttainments,
1379 http_status,
1380 detail.as_ref(),
1381 )
1382 }
1383
1384 fn unpaired(sent: &[&str], items: &[SuotarResponseItem<PersonResult>]) -> UnpairedItemIds {
1385 let sent: Vec<String> = sent.iter().map(|id| (*id).to_string()).collect();
1386 unpaired_item_ids(&sent, items)
1387 }
1388
1389 fn reconciled(
1390 sent: &[&str],
1391 items: Vec<SuotarResponseItem<PersonResult>>,
1392 ) -> SuotarBatchResponse<PersonResult> {
1393 reconcile(
1394 SuotarEndpoint::ResolvePersons,
1395 sent.iter().map(|id| (*id).to_string()).collect(),
1396 items,
1397 Duration::ZERO,
1398 Arc::new(json!([])),
1399 )
1400 }
1401
1402 #[test]
1405 fn every_endpoint_joins_onto_the_configured_base() {
1406 let client = SuotarClient::mock_for_test();
1407 let joined: Vec<String> = [
1408 SuotarEndpoint::ResolvePersons,
1409 SuotarEndpoint::ResolveEnrolments,
1410 SuotarEndpoint::ImportAttainments,
1411 SuotarEndpoint::VerifyAttainments,
1412 SuotarEndpoint::ListByCourse,
1413 SuotarEndpoint::ValidateCourseCodes,
1414 ]
1415 .iter()
1416 .map(|endpoint| {
1417 client
1418 .api_base_url
1419 .join(endpoint.path())
1420 .expect("joins")
1421 .to_string()
1422 })
1423 .collect();
1424 assert_eq!(
1425 joined,
1426 vec![
1427 "http://project-331.local/api/v0/mock-suotar/persons/resolve-by-student-numbers",
1428 "http://project-331.local/api/v0/mock-suotar/enrolments/resolve",
1429 "http://project-331.local/api/v0/mock-suotar/attainments/import",
1430 "http://project-331.local/api/v0/mock-suotar/attainments/verify",
1431 "http://project-331.local/api/v0/mock-suotar/enrolments/list-by-course",
1432 "http://project-331.local/api/v0/mock-suotar/course-codes/validate",
1433 ]
1434 );
1435 }
1436
1437 #[test]
1438 fn a_request_batch_serializes_to_the_documented_shape() {
1439 let items = vec![ImportAttainmentRequestItem {
1440 request_item_id: "11111111-1111-1111-1111-111111111111".to_string(),
1441 student_number: "012345678".into(),
1442 course_code: "TKT10001".to_string(),
1443 enrolment_id: "selected-enrolment-id".to_string(),
1444 attainment_date: "2026-05-22T09:00:00Z".parse().expect("valid instant"),
1445 attainment_language: "fi".to_string(),
1446 grade_scale_id: "sis-hyl-hyv".to_string(),
1447 grade_id: "1".to_string(),
1448 credits: 5.0,
1449 }];
1450 assert_eq!(
1451 serde_json::to_value(&items).expect("serializes"),
1452 json!([{
1453 "requestItemId": "11111111-1111-1111-1111-111111111111",
1454 "studentNumber": "012345678",
1455 "courseCode": "TKT10001",
1456 "enrolmentId": "selected-enrolment-id",
1457 "attainmentDate": "2026-05-22T09:00:00Z",
1458 "attainmentLanguage": "fi",
1459 "gradeScaleId": "sis-hyl-hyv",
1460 "gradeId": "1",
1461 "credits": 5.0
1462 }])
1463 );
1464 }
1465
1466 #[test]
1467 fn an_error_item_deserializes_without_a_result() {
1468 let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1469 "requestItemId": "b2",
1470 "status": "error",
1471 "code": "personNotFound",
1472 "error": { "message": "No Sisu person was found for the supplied student number." }
1473 }]))
1474 .expect("error item");
1475 assert_eq!(items[0].status, SuotarItemStatus::Error);
1476 assert!(items[0].result.is_none());
1477 assert_eq!(
1478 items[0].error.as_ref().map(|error| error.message.as_str()),
1479 Some("No Sisu person was found for the supplied student number.")
1480 );
1481 }
1482
1483 #[test]
1484 fn a_sisu_timeout_carries_the_id_the_client_may_verify() {
1485 let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1486 serde_json::from_value(json!([{
1487 "requestItemId": "item-1",
1488 "status": "error",
1489 "code": "sisuTimeout",
1490 "error": { "message": "Sisu operation timed out; outcome is uncertain." },
1491 "result": {
1492 "submittedAttainmentId": "hy-kur-1",
1493 "submittedAttainmentType": "AssessmentItemAttainment"
1494 }
1495 }]))
1496 .expect("timeout with an id");
1497 assert_eq!(
1498 items[0]
1499 .result
1500 .as_ref()
1501 .and_then(|result| result.submitted_attainment_id.as_deref()),
1502 Some("hy-kur-1")
1503 );
1504 }
1505
1506 #[test]
1507 fn an_enrolment_error_carries_the_existing_attainments() {
1508 let items: Vec<SuotarResponseItem<EnrolmentResolutionResult>> =
1509 serde_json::from_value(json!([{
1510 "requestItemId": "item-1",
1511 "status": "error",
1512 "code": "enrolmentNotFound",
1513 "error": { "message": "No Sisu enrolment was found for this person and course." },
1514 "result": { "existingAttainments": [{
1515 "id": "existing-attainment-id",
1516 "type": "AssessmentItemAttainment",
1517 "state": "ATTAINED",
1518 "attainmentDate": "2026-03-01",
1519 "registrationDate": "2026-03-05",
1520 "gradeScaleId": "sis-0-5",
1521 "gradeId": 3
1522 }] }
1523 }]))
1524 .expect("enrolment error with attainments");
1525 let result = items[0].result.as_ref().expect("result");
1526 assert!(result.enrolments.is_empty());
1527 assert_eq!(
1528 result.existing_attainments[0].grade_id.as_deref(),
1529 Some("3")
1530 );
1531 }
1532
1533 #[test]
1534 fn one_deserializer_covers_every_import_success_body() {
1535 let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1536 serde_json::from_value(json!([
1537 {
1538 "requestItemId": "item-1",
1539 "status": "ok",
1540 "code": "sent",
1541 "result": {
1542 "submittedAttainmentId": "hy-kur-1",
1543 "submittedAttainmentType": "AssessmentItemAttainment"
1544 }
1545 },
1546 {
1547 "requestItemId": "item-3",
1548 "status": "ok",
1549 "code": "duplicateAttainment",
1550 "result": { "attainment": {
1551 "id": "existing-id",
1552 "type": "CourseUnitAttainment",
1553 "state": "ATTAINED",
1554 "attainmentDate": "2026-05-22T00:00:00.000Z",
1555 "registrationDate": "2026-05-22T00:00:00.000Z",
1556 "gradeScaleId": "sis-hyl-hyv",
1557 "gradeId": "1"
1558 } }
1559 },
1560 {
1561 "requestItemId": "item-5",
1562 "status": "ok",
1563 "code": "duplicateAttainment",
1564 "result": { "attainment": {
1565 "id": "hy-kur-1",
1566 "type": "AssessmentItemAttainment",
1567 "attainmentDate": "2026-05-22",
1568 "gradeScaleId": "sis-hyl-hyv",
1569 "gradeId": "1"
1570 } }
1571 },
1572 {
1573 "requestItemId": "item-4",
1574 "status": "ok",
1575 "code": "notImprovedAttainment",
1576 "result": { "previousAttainment": {
1577 "id": "existing-id",
1578 "type": "CourseUnitAttainment",
1579 "state": "ATTAINED",
1580 "gradeScaleId": "sis-0-5",
1581 "gradeId": "5",
1582 "attainmentDate": "2026-03-01",
1583 "registrationDate": "2026-03-05"
1584 } }
1585 }
1586 ]))
1587 .expect("import successes");
1588
1589 let sent = items[0].result.as_ref().expect("sent result");
1590 assert_eq!(sent.submitted_attainment_id.as_deref(), Some("hy-kur-1"));
1591 let duplicate = items[1].result.as_ref().expect("duplicate result");
1592 assert_eq!(
1593 duplicate
1594 .attainment
1595 .as_ref()
1596 .and_then(|attainment| attainment.grade_id.as_deref()),
1597 Some("1")
1598 );
1599 assert_eq!(
1600 duplicate
1601 .attainment
1602 .as_ref()
1603 .and_then(|attainment| attainment.attainment_date),
1604 NaiveDate::from_ymd_opt(2026, 5, 22)
1605 );
1606 let recently_sent = items[2]
1607 .result
1608 .as_ref()
1609 .and_then(|result| result.attainment.as_ref())
1610 .expect("recently sent duplicate");
1611 assert_eq!(recently_sent.id, "hy-kur-1");
1612 assert_eq!(recently_sent.state, None);
1613 assert_eq!(recently_sent.registration_date, None);
1614 let not_improved = items[3].result.as_ref().expect("not improved result");
1615 assert_eq!(
1616 not_improved
1617 .previous_attainment
1618 .as_ref()
1619 .map(|attainment| attainment.id.as_str()),
1620 Some("existing-id")
1621 );
1622 }
1623
1624 #[test]
1625 fn an_unknown_code_does_not_fail_deserialization() {
1626 let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1627 "requestItemId": "a1",
1628 "status": "error",
1629 "code": "somethingSuotarAddedLater",
1630 "error": { "message": "..." }
1631 }]))
1632 .expect("unknown code");
1633 assert_eq!(items[0].code, "somethingSuotarAddedLater");
1634 }
1635
1636 #[test]
1637 fn items_are_matched_by_request_item_id_not_position() {
1638 let items = person_response(&["c3", "a1", "b2"]);
1639 assert_eq!(
1640 unpaired(&["a1", "b2", "c3"], &items),
1641 UnpairedItemIds {
1642 missing: Vec::new(),
1643 unexpected: Vec::new(),
1644 }
1645 );
1646 let response = reconciled(&["a1", "b2", "c3"], items);
1647 assert_eq!(
1648 response.item("b2").map(|item| item.code.as_str()),
1649 Some("personFound")
1650 );
1651 }
1652
1653 #[test]
1654 fn an_unanswered_item_is_reported_rather_than_paired_with_a_neighbour() {
1655 let items = person_response(&["c3", "a1"]);
1656 assert_eq!(
1657 unpaired(&["a1", "b2", "c3"], &items).missing,
1658 vec!["b2".to_string()]
1659 );
1660 let response = reconciled(&["a1", "b2", "c3"], items);
1661 assert!(response.item("b2").is_none());
1662 assert!(response.item("c3").is_some());
1663 }
1664
1665 #[test]
1666 fn an_item_id_that_was_never_sent_is_reported_and_kept_out_of_the_way() {
1667 assert_eq!(
1668 unpaired(&["a1"], &person_response(&["a1", "z9"])),
1669 UnpairedItemIds {
1670 missing: Vec::new(),
1671 unexpected: vec!["z9".to_string()],
1672 }
1673 );
1674 }
1675
1676 #[test]
1677 fn a_repeated_request_item_id_is_refused_before_the_request_is_built() {
1678 let error = check_batch(SuotarEndpoint::ResolvePersons, &person_items(&["a1", "a1"]))
1679 .expect_err("duplicate ids");
1680 assert!(error.message().contains("repeats requestItemId `a1`"));
1681 }
1682
1683 #[test]
1684 fn a_batch_over_the_endpoints_size_is_refused_before_the_request_is_built() {
1685 let items: Vec<ResolvePersonRequestItem> = (0..1001)
1686 .map(|index| ResolvePersonRequestItem {
1687 request_item_id: format!("item-{index}"),
1688 student_number: "012345678".into(),
1689 })
1690 .collect();
1691 for (endpoint, size) in [
1692 (SuotarEndpoint::ListByCourse, 50),
1693 (SuotarEndpoint::ImportAttainments, 100),
1694 (SuotarEndpoint::ResolvePersons, 1000),
1695 (SuotarEndpoint::VerifyAttainments, 1000),
1696 ] {
1697 assert_eq!(endpoint.max_batch_size(), size, "{endpoint:?}");
1698 assert!(
1699 check_batch(endpoint, &items[..size]).is_ok(),
1700 "{endpoint:?}"
1701 );
1702 assert!(
1703 check_batch(endpoint, &items[..size + 1]).is_err(),
1704 "{endpoint:?}"
1705 );
1706 }
1707 }
1708
1709 #[test]
1710 fn the_documented_request_level_bodies_classify() {
1711 let unauthorized = classified(
1712 401,
1713 r#"{"error":{"code":"unauthorized","message":"Missing or invalid credentials."}}"#,
1714 );
1715 assert_eq!(unauthorized.variant, SuotarErrorVariant::Unauthorized);
1716
1717 let malformed = classified(
1718 400,
1719 r#"{"error":{"code":"malformedRequest","message":"Request body is not valid JSON or has the wrong top-level shape."}}"#,
1720 );
1721 assert_eq!(malformed.variant, SuotarErrorVariant::MalformedRequest);
1722
1723 let too_large = classified(
1724 413,
1725 r#"{"error":{"code":"requestTooLarge","message":"Request body is too large."}}"#,
1726 );
1727 assert_eq!(too_large.variant, SuotarErrorVariant::MalformedRequest);
1728
1729 let unavailable = classified(
1730 503,
1731 r#"{"error":{"code":"serviceTemporarilyUnavailable","message":"Failed to fetch Sisu data."}}"#,
1732 );
1733 assert_eq!(
1734 unavailable.variant,
1735 SuotarErrorVariant::ServiceTemporarilyUnavailable
1736 );
1737
1738 let internal = classified(
1739 500,
1740 r#"{"error":{"code":"internalError","message":"Suotar failed to process the request."}}"#,
1741 );
1742 assert_eq!(internal.variant, SuotarErrorVariant::ServerError);
1743
1744 let unserved_path = classified(401, r#"{"error":"Unauthorized access"}"#);
1745 assert_eq!(unserved_path.variant, SuotarErrorVariant::RequestLevelError);
1746 assert!(unserved_path.message().contains("SUOTAR_API_BASE_URL"));
1747
1748 let bodyless = classified(502, "<html>");
1749 assert_eq!(bodyless.variant, SuotarErrorVariant::ServerError);
1750 }
1751}