Skip to main content

headless_lms_utils/services/
suotar.rs

1//! Client for Suotar, the University of Helsinki study registry.
2//!
3//! Every endpoint is a batch. Per-item outcomes arrive as HTTP 200 and are read from each item's
4//! `status` and `code`; only request-level failures are 4xx/5xx and `Err`. Items are matched back
5//! by `requestItemId`, never by position.
6//!
7//! The mock study registry serializes these same types, so `skip_serializing_if` here is what keeps
8//! its bodies byte-identical to Suotar's.
9
10use std::collections::HashSet;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use chrono::NaiveDate;
17use headless_lms_base::config::{MOCK_SUOTAR_TOKEN, SUOTAR_AUTH_SCHEME, SuotarConfiguration};
18use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
19use secrecy::{ExposeSecret, SecretString};
20use serde::Deserialize;
21use serde::de::DeserializeOwned;
22use utoipa::ToSchema;
23
24use crate::{error::util_error::SuotarErrorVariant, prelude::*};
25
26/// Bounds one call so a Suotar that never answers cannot stall a worker tick.
27pub const SUOTAR_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
28
29/// Carries `suotar_api_calls.id` so Suotar's log and ours join on one value.
30pub const CORRELATION_ID_HEADER: &str = "X-Correlation-Id";
31
32/// Matches the actix payload limit the mock Suotar runs behind, so an oversized batch is refused
33/// here rather than 413'd at the far end.
34pub const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
35
36/// The only code the contract classifies as "transient, retry me".
37pub const TRANSIENT_ITEM_CODE: &str = "sisuTemporarilyUnavailable";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type, ToSchema)]
40#[sqlx(type_name = "suotar_endpoint", rename_all = "snake_case")]
41#[serde(rename_all = "snake_case")]
42pub enum SuotarEndpoint {
43    ResolvePersons,
44    ResolveEnrolments,
45    ImportAttainments,
46    VerifyAttainments,
47    ProductAccessTokens,
48    ListByCourse,
49}
50
51impl SuotarEndpoint {
52    /// Relative to the configured base url, which ends in `/`.
53    pub fn path(self) -> &'static str {
54        match self {
55            Self::ResolvePersons => "persons/resolve-by-student-numbers",
56            Self::ResolveEnrolments => "enrolments/resolve",
57            Self::ImportAttainments => "attainments/import",
58            Self::VerifyAttainments => "attainments/verify",
59            Self::ProductAccessTokens => "open-university-product-access-tokens/resolve",
60            Self::ListByCourse => "enrolments/list-by-course",
61        }
62    }
63
64    /// ListByCourse is smallest because each response item carries a full person per enrolment;
65    /// ImportAttainments is next because a request-level failure re-queues the whole batch.
66    pub fn max_batch_size(self) -> usize {
67        match self {
68            Self::ResolvePersons | Self::ResolveEnrolments | Self::ProductAccessTokens => 50,
69            Self::ImportAttainments => 25,
70            Self::VerifyAttainments => 100,
71            Self::ListByCourse => 10,
72        }
73    }
74
75    /// An item this endpoint never answered is uncertain, not retryable: re-sending it can put a
76    /// second attainment on a real transcript.
77    pub fn creates_attainments(self) -> bool {
78        matches!(self, Self::ImportAttainments)
79    }
80
81    /// `resolve-enrolments` and `import` carry the transient failure only at the request level.
82    pub fn carries_item_level_transient(self) -> bool {
83        matches!(
84            self,
85            Self::ResolvePersons
86                | Self::VerifyAttainments
87                | Self::ProductAccessTokens
88                | Self::ListByCourse
89        )
90    }
91}
92
93/// Sent verbatim from `credit_registrations.request_item_id`; Suotar echoes it back, which is what
94/// makes a reordered or partial response safe to read.
95pub trait SuotarRequestItem: Serialize {
96    fn request_item_id(&self) -> &str;
97}
98
99macro_rules! request_item {
100    ($name:ident) => {
101        impl SuotarRequestItem for $name {
102            fn request_item_id(&self) -> &str {
103                &self.request_item_id
104            }
105        }
106    };
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ResolvePersonRequestItem {
112    pub request_item_id: String,
113    pub student_number: String,
114}
115request_item!(ResolvePersonRequestItem);
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ResolveEnrolmentRequestItem {
120    pub request_item_id: String,
121    pub student_number: String,
122    pub course_code: String,
123}
124request_item!(ResolveEnrolmentRequestItem);
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct ImportAttainmentRequestItem {
129    pub request_item_id: String,
130    pub student_number: String,
131    pub course_code: String,
132    pub enrolment_id: String,
133    pub attainment_date: NaiveDate,
134    pub attainment_language: String,
135    pub grade_scale_id: String,
136    pub grade_id: String,
137    pub credits: f64,
138}
139request_item!(ImportAttainmentRequestItem);
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct VerifyAttainmentRequestItem {
144    pub request_item_id: String,
145    pub submitted_attainment_id: String,
146}
147request_item!(VerifyAttainmentRequestItem);
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ProductAccessTokenRequestItem {
152    pub request_item_id: String,
153    pub open_university_product_id: String,
154}
155request_item!(ProductAccessTokenRequestItem);
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct ListByCourseRequestItem {
160    pub request_item_id: String,
161    pub course_code: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub course_unit_realisation_id: Option<String>,
164}
165request_item!(ListByCourseRequestItem);
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct LocalizedName {
170    pub fi: String,
171    pub sv: String,
172    pub en: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct DatePeriod {
178    pub start_date: NaiveDate,
179    pub end_date: NaiveDate,
180}
181
182impl DatePeriod {
183    pub fn contains(&self, date: NaiveDate) -> bool {
184        self.start_date <= date && date <= self.end_date
185    }
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct CreditRange {
191    pub min: f64,
192    pub max: f64,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct PersonResult {
198    pub student_number: String,
199    pub person_id: String,
200    pub first_names: String,
201    pub last_name: String,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct SuotarEnrolment {
207    pub id: String,
208    pub state: String,
209    pub kind: String,
210    pub course_unit_id: String,
211    pub assessment_item_id: String,
212    pub course_unit_realisation_id: String,
213    pub course_unit_realisation_name: LocalizedName,
214    pub activity_period: DatePeriod,
215    pub grade_scale_id: String,
216    pub credits: CreditRange,
217    pub study_right_id: String,
218    pub study_right_validity_period: DatePeriod,
219    pub enrolment_date_time: DateTime<Utc>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct ExistingAttainment {
225    pub id: String,
226    #[serde(rename = "type")]
227    pub attainment_type: String,
228    pub state: String,
229    pub person_id: String,
230    pub course_unit_id: String,
231    pub assessment_item_id: String,
232    pub course_unit_realisation_id: String,
233    pub attainment_date: NaiveDate,
234    pub registration_date: NaiveDate,
235    pub grade_scale_id: String,
236    pub grade_id: String,
237    pub passed: bool,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct EnrolmentResolutionResult {
243    pub enrolments: Vec<SuotarEnrolment>,
244    #[serde(default)]
245    pub existing_attainments: Vec<ExistingAttainment>,
246}
247
248/// Covers both contract bodies: the bare `{id, type}` of a `registered` answer and the fuller one
249/// behind `duplicateAttainment` and `notImprovedAttainment`.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct SuotarAttainment {
253    pub id: String,
254    #[serde(rename = "type")]
255    pub attainment_type: String,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub state: Option<String>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub attainment_date: Option<NaiveDate>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub registration_date: Option<NaiveDate>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub grade_scale_id: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub grade_id: Option<String>,
266}
267
268/// One shape for import's four success codes: `sent` fills the submitted pair, `registered` and
269/// `duplicateAttainment` fill `attainment`, `notImprovedAttainment` fills `previous_attainment`.
270#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct ImportAttainmentResult {
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub submitted_attainment_id: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub submitted_attainment_type: Option<String>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub attainment: Option<SuotarAttainment>,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub previous_attainment: Option<SuotarAttainment>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct VerifyAttainmentResult {
286    pub attainment: SuotarAttainment,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct ProductAccessTokenResult {
292    pub id: String,
293    pub access_token: String,
294    pub state: String,
295    pub document_state: String,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct ListedEnrolment {
301    pub id: String,
302    pub course_unit_realisation_id: String,
303    pub state: String,
304    pub enrolment_date_time: DateTime<Utc>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct ListedPerson {
310    pub student_number: String,
311    pub person_id: String,
312    pub first_names: String,
313    pub last_name: String,
314    pub primary_email: String,
315    /// Omitted rather than null when Sisu holds none.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub secondary_email: Option<String>,
318    pub enrolment: ListedEnrolment,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct EnrolmentsListedResult {
324    pub people: Vec<ListedPerson>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase")]
329pub enum SuotarItemStatus {
330    Ok,
331    Error,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct SuotarItemError {
337    pub message: String,
338    /// Present only on a disclosed `sisuTimeout`: the id the client may verify instead of retrying.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub submitted_attainment_id: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct SuotarResponseItem<R> {
346    pub request_item_id: String,
347    pub status: SuotarItemStatus,
348    /// A string, not an enum: Suotar may add codes, and a strict enum would take the pipeline down
349    /// the day it does.
350    pub code: String,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub result: Option<R>,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub error: Option<SuotarItemError>,
355}
356
357#[derive(Debug)]
358pub struct SuotarBatchResponse<R> {
359    pub endpoint: SuotarEndpoint,
360    pub items: Vec<SuotarResponseItem<R>>,
361    /// Sent, but answered by nothing. Unknown outcome on [`SuotarEndpoint::creates_attainments`].
362    pub missing_request_item_ids: Vec<String>,
363    /// Answered, but never sent. Logged and otherwise ignored.
364    pub unexpected_request_item_ids: Vec<String>,
365    /// Zero when the batch was empty and no call was made.
366    pub http_status: u16,
367    pub duration: Duration,
368    /// `suotar_api_calls.id`, absent only when the audit write itself failed.
369    pub call_id: Option<Uuid>,
370    /// Unscrubbed; scrub before persisting any part of it. Shared with the audit record rather than
371    /// copied, since `list-by-course` bodies are the largest the pipeline handles.
372    pub raw_response: Arc<serde_json::Value>,
373}
374
375impl<R> SuotarBatchResponse<R> {
376    pub fn item(&self, request_item_id: &str) -> Option<&SuotarResponseItem<R>> {
377        self.items
378            .iter()
379            .find(|item| item.request_item_id == request_item_id)
380    }
381}
382
383/// Both fields are audit-row columns: `worker_name` separates the submitter from the verify poller
384/// from a manual retry, and the ids replace the identifiers scrubbing removes from stored bodies.
385#[derive(Debug, Clone, Default)]
386pub struct SuotarCallContext {
387    pub worker_name: String,
388    pub credit_registration_ids: Vec<Uuid>,
389}
390
391impl SuotarCallContext {
392    pub fn new(worker_name: impl Into<String>) -> Self {
393        Self {
394            worker_name: worker_name.into(),
395            credit_registration_ids: Vec::new(),
396        }
397    }
398
399    pub fn for_registrations(mut self, ids: Vec<Uuid>) -> Self {
400        self.credit_registration_ids = ids;
401        self
402    }
403}
404
405#[derive(Debug, Clone)]
406pub struct SuotarCallStarted {
407    pub endpoint: SuotarEndpoint,
408    pub request_item_count: usize,
409    pub worker_name: String,
410    pub credit_registration_ids: Vec<Uuid>,
411    pub started_at: DateTime<Utc>,
412    /// Unscrubbed; the implementation scrubs before it persists anything.
413    pub request_body: serde_json::Value,
414}
415
416#[derive(Debug, Clone, Default)]
417pub struct SuotarCallFinished {
418    pub http_status: Option<u16>,
419    pub duration: Duration,
420    pub succeeded: bool,
421    pub ok_item_count: usize,
422    pub error_item_count: usize,
423    pub request_level_error_code: Option<String>,
424    pub error_message: Option<String>,
425    /// Unscrubbed; the implementation scrubs before it persists anything.
426    pub response_body: Option<Arc<serde_json::Value>>,
427}
428
429/// Persists one `suotar_api_calls` row per call. A trait because the table is in the models crate,
430/// which depends on this one. Implementations must scrub the bodies.
431#[async_trait]
432pub trait SuotarCallAudit: Send + Sync {
433    /// Returns the row id, which travels out as [`CORRELATION_ID_HEADER`]. `None` means the row
434    /// could not be written; the call goes out anyway.
435    async fn started(&self, started: SuotarCallStarted) -> Option<Uuid>;
436
437    async fn finished(&self, call_id: Uuid, finished: SuotarCallFinished);
438}
439
440pub struct NoSuotarCallAudit;
441
442#[async_trait]
443impl SuotarCallAudit for NoSuotarCallAudit {
444    async fn started(&self, _started: SuotarCallStarted) -> Option<Uuid> {
445        None
446    }
447
448    async fn finished(&self, _call_id: Uuid, _finished: SuotarCallFinished) {}
449}
450
451/// Suotar's legacy study-registry path takes the token verbatim after the scheme word, not base64
452/// of `user:password`.
453fn authorization_header_value(token: &str) -> String {
454    format!("{SUOTAR_AUTH_SCHEME} {token}")
455}
456
457#[derive(Clone)]
458pub struct SuotarClient {
459    api_base_url: Url,
460    authorization: SecretString,
461    audit: Arc<dyn SuotarCallAudit>,
462    /// Requests that actually left for the study registry, shared by every clone of the client.
463    /// The circuit breaker reads it to tell an iteration that heard from the registry from one that
464    /// found nothing to ask about; a pre-flight refusal is not counted because it never reached it.
465    exchanges: Arc<AtomicU64>,
466}
467
468impl SuotarClient {
469    pub fn new(config: &SuotarConfiguration, audit: Arc<dyn SuotarCallAudit>) -> Self {
470        Self {
471            api_base_url: config.api_base_url.clone(),
472            authorization: SecretString::new(
473                authorization_header_value(config.api_token.expose_secret()).into(),
474            ),
475            audit,
476            exchanges: Arc::new(AtomicU64::new(0)),
477        }
478    }
479
480    pub fn mock_for_test() -> Self {
481        Self {
482            api_base_url: Url::parse("http://project-331.local/api/v0/mock-suotar/")
483                .expect("hardcoded url"),
484            authorization: SecretString::new(authorization_header_value(MOCK_SUOTAR_TOKEN).into()),
485            audit: Arc::new(NoSuotarCallAudit),
486            exchanges: Arc::new(AtomicU64::new(0)),
487        }
488    }
489
490    /// How many requests this client has sent, monotonic for the life of the process. Compare two
491    /// readings to learn whether the work between them reached the study registry at all.
492    pub fn exchange_count(&self) -> u64 {
493        self.exchanges.load(Ordering::Relaxed)
494    }
495
496    pub async fn resolve_persons(
497        &self,
498        context: SuotarCallContext,
499        items: Vec<ResolvePersonRequestItem>,
500    ) -> UtilResult<SuotarBatchResponse<PersonResult>> {
501        self.post_batch(SuotarEndpoint::ResolvePersons, context, items)
502            .await
503    }
504
505    pub async fn resolve_enrolments(
506        &self,
507        context: SuotarCallContext,
508        items: Vec<ResolveEnrolmentRequestItem>,
509    ) -> UtilResult<SuotarBatchResponse<EnrolmentResolutionResult>> {
510        self.post_batch(SuotarEndpoint::ResolveEnrolments, context, items)
511            .await
512    }
513
514    pub async fn import_attainments(
515        &self,
516        context: SuotarCallContext,
517        items: Vec<ImportAttainmentRequestItem>,
518    ) -> UtilResult<SuotarBatchResponse<ImportAttainmentResult>> {
519        self.post_batch(SuotarEndpoint::ImportAttainments, context, items)
520            .await
521    }
522
523    pub async fn verify_attainments(
524        &self,
525        context: SuotarCallContext,
526        items: Vec<VerifyAttainmentRequestItem>,
527    ) -> UtilResult<SuotarBatchResponse<VerifyAttainmentResult>> {
528        self.post_batch(SuotarEndpoint::VerifyAttainments, context, items)
529            .await
530    }
531
532    pub async fn resolve_product_access_tokens(
533        &self,
534        context: SuotarCallContext,
535        items: Vec<ProductAccessTokenRequestItem>,
536    ) -> UtilResult<SuotarBatchResponse<ProductAccessTokenResult>> {
537        self.post_batch(SuotarEndpoint::ProductAccessTokens, context, items)
538            .await
539    }
540
541    pub async fn list_enrolments_by_course(
542        &self,
543        context: SuotarCallContext,
544        items: Vec<ListByCourseRequestItem>,
545    ) -> UtilResult<SuotarBatchResponse<EnrolmentsListedResult>> {
546        self.post_batch(SuotarEndpoint::ListByCourse, context, items)
547            .await
548    }
549
550    async fn post_batch<T: SuotarRequestItem, R: DeserializeOwned>(
551        &self,
552        endpoint: SuotarEndpoint,
553        context: SuotarCallContext,
554        items: Vec<T>,
555    ) -> UtilResult<SuotarBatchResponse<R>> {
556        if items.is_empty() {
557            return Ok(empty_batch_response(endpoint));
558        }
559        // Serialized once: the audited body and the wire body must be byte-for-byte the same.
560        let request_body = serde_json::to_value(&items)?;
561        let encoded = serde_json::to_vec(&request_body)?;
562        // Before the pre-flight checks, so a refused batch still leaves an audit row to diagnose.
563        let call_id = self
564            .audit
565            .started(SuotarCallStarted {
566                endpoint,
567                request_item_count: items.len(),
568                worker_name: context.worker_name,
569                credit_registration_ids: context.credit_registration_ids,
570                started_at: Utc::now(),
571                request_body,
572            })
573            .await;
574        if call_id.is_none() {
575            error!(
576                "Could not write a suotar_api_calls row for a {} call; sending it unaudited.",
577                endpoint.path()
578            );
579        }
580
581        let sent_ids = match check_batch(endpoint, &items) {
582            Ok(sent_ids) => sent_ids,
583            Err(error) => return self.refused(call_id, error).await,
584        };
585        if encoded.len() > MAX_REQUEST_BODY_BYTES {
586            return self
587                .refused(
588                    call_id,
589                    util_err!(
590                        SuotarClientError(SuotarErrorVariant::MalformedRequest),
591                        format!(
592                            "A {} request of {} items encodes to {} bytes, over the {MAX_REQUEST_BODY_BYTES} byte limit.",
593                            endpoint.path(),
594                            sent_ids.len(),
595                            encoded.len()
596                        )
597                    ),
598                )
599                .await;
600        }
601
602        let url = self.api_base_url.join(endpoint.path())?;
603        let clock = Instant::now();
604        let mut request = REQWEST_CLIENT
605            .post(url)
606            .timeout(SUOTAR_REQUEST_TIMEOUT)
607            .header(AUTHORIZATION, self.authorization.expose_secret())
608            .header(CONTENT_TYPE, "application/json");
609        if let Some(call_id) = call_id {
610            request = request.header(CORRELATION_ID_HEADER, call_id.to_string());
611        }
612
613        self.exchanges.fetch_add(1, Ordering::Relaxed);
614        let (mut outcome, finished) = self
615            .exchange(endpoint, request, encoded, sent_ids, clock)
616            .await;
617        if let Ok(response) = &mut outcome {
618            response.call_id = call_id;
619        }
620        if let Some(call_id) = call_id {
621            self.audit.finished(call_id, finished).await;
622        }
623        outcome
624    }
625
626    /// Records a pre-flight refusal as the `suotar_api_calls` row any other failure would leave.
627    async fn refused<R>(
628        &self,
629        call_id: Option<Uuid>,
630        error: UtilError,
631    ) -> UtilResult<SuotarBatchResponse<R>> {
632        if let Some(call_id) = call_id {
633            self.audit
634                .finished(
635                    call_id,
636                    SuotarCallFinished {
637                        error_message: Some(error.message().to_string()),
638                        ..SuotarCallFinished::default()
639                    },
640                )
641                .await;
642        }
643        Err(error)
644    }
645
646    /// Returns the audit record alongside the result: only this function knows the status, the
647    /// duration and the request-level code, and the row needs all three.
648    async fn exchange<R: DeserializeOwned>(
649        &self,
650        endpoint: SuotarEndpoint,
651        request: reqwest::RequestBuilder,
652        body: Vec<u8>,
653        sent_ids: Vec<String>,
654        clock: Instant,
655    ) -> Exchanged<R> {
656        let response = match request.body(body).send().await {
657            Ok(response) => response,
658            Err(error) => {
659                return failed(
660                    util_err!(
661                        SuotarClientError(transport_variant(&error)),
662                        format!("Request to Suotar {} failed", endpoint.path()),
663                        error
664                    ),
665                    None,
666                    clock.elapsed(),
667                    None,
668                    None,
669                );
670            }
671        };
672
673        let http_status = response.status().as_u16();
674        let text = match response.text().await {
675            Ok(text) => text,
676            Err(error) => {
677                return failed(
678                    util_err!(
679                        SuotarClientError(transport_variant(&error)),
680                        format!(
681                            "Reading the Suotar {} response body failed",
682                            endpoint.path()
683                        ),
684                        error
685                    ),
686                    Some(http_status),
687                    clock.elapsed(),
688                    None,
689                    None,
690                );
691            }
692        };
693        let duration = clock.elapsed();
694
695        if !(200..300).contains(&http_status) {
696            let detail = serde_json::from_str::<RequestLevelErrorBody>(&text)
697                .ok()
698                .map(|parsed| parsed.error);
699            let code = detail.as_ref().map(|detail| detail.code.clone());
700            let error = request_level_error(endpoint, http_status, detail.as_ref());
701            return failed(
702                error,
703                Some(http_status),
704                duration,
705                code,
706                Some(Arc::new(body_for_audit(&text))),
707            );
708        }
709
710        let raw_response: Arc<serde_json::Value> = match serde_json::from_str(&text) {
711            Ok(value) => Arc::new(value),
712            Err(error) => {
713                return failed(
714                    util_err!(
715                        SuotarClientError(SuotarErrorVariant::Deserialization),
716                        format!(
717                            "Suotar {} answered {http_status} with a body that is not JSON",
718                            endpoint.path()
719                        ),
720                        error
721                    ),
722                    Some(http_status),
723                    duration,
724                    None,
725                    Some(Arc::new(body_for_audit(&text))),
726                );
727            }
728        };
729        let Some(array) = raw_response.as_array() else {
730            return failed(
731                util_err!(
732                    SuotarClientError(SuotarErrorVariant::Deserialization),
733                    format!(
734                        "Suotar {} answered {http_status} with a body that is not a batch response",
735                        endpoint.path()
736                    )
737                ),
738                Some(http_status),
739                duration,
740                None,
741                Some(raw_response),
742            );
743        };
744        // Item by item, so one malformed entry costs only its own row: parsing the array as a whole
745        // would park every other row of the batch as unanswered too. `reconcile` then reports the
746        // dropped ids as missing, which is what an item we cannot read amounts to.
747        let items: Vec<SuotarResponseItem<R>> = array
748            .iter()
749            .filter_map(|item| match SuotarResponseItem::<R>::deserialize(item) {
750                Ok(parsed) => Some(parsed),
751                Err(error) => {
752                    error!(
753                        "Suotar {} answered with an item that could not be read; treating it as unanswered: {error}",
754                        endpoint.path()
755                    );
756                    None
757                }
758            })
759            .collect();
760
761        let response = reconcile(
762            endpoint,
763            sent_ids,
764            items,
765            http_status,
766            duration,
767            raw_response,
768        );
769        let finished = SuotarCallFinished {
770            http_status: Some(http_status),
771            duration,
772            succeeded: true,
773            ok_item_count: response
774                .items
775                .iter()
776                .filter(|item| item.status == SuotarItemStatus::Ok)
777                .count(),
778            error_item_count: response
779                .items
780                .iter()
781                .filter(|item| item.status == SuotarItemStatus::Error)
782                .count(),
783            request_level_error_code: None,
784            error_message: None,
785            response_body: Some(Arc::clone(&response.raw_response)),
786        };
787        (Ok(response), finished)
788    }
789}
790
791type Exchanged<R> = (UtilResult<SuotarBatchResponse<R>>, SuotarCallFinished);
792
793fn failed<R>(
794    error: UtilError,
795    http_status: Option<u16>,
796    duration: Duration,
797    request_level_error_code: Option<String>,
798    response_body: Option<Arc<serde_json::Value>>,
799) -> Exchanged<R> {
800    let finished = SuotarCallFinished {
801        http_status,
802        duration,
803        succeeded: false,
804        ok_item_count: 0,
805        error_item_count: 0,
806        request_level_error_code,
807        error_message: Some(error.message().to_string()),
808        response_body,
809    };
810    (Err(error), finished)
811}
812
813/// A body that is not JSON is still worth keeping; the scrubber takes a bare string too.
814fn body_for_audit(text: &str) -> serde_json::Value {
815    serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
816}
817
818/// Refuses our own bugs before a request goes out; both would come back as a request-level error
819/// rejecting the whole batch.
820fn check_batch<T: SuotarRequestItem>(
821    endpoint: SuotarEndpoint,
822    items: &[T],
823) -> UtilResult<Vec<String>> {
824    if items.len() > endpoint.max_batch_size() {
825        return Err(util_err!(
826            SuotarClientError(SuotarErrorVariant::MalformedRequest),
827            format!(
828                "A {} request carries {} items, over the batch size of {}.",
829                endpoint.path(),
830                items.len(),
831                endpoint.max_batch_size()
832            )
833        ));
834    }
835    let mut seen = HashSet::with_capacity(items.len());
836    for item in items {
837        if !seen.insert(item.request_item_id()) {
838            return Err(util_err!(
839                SuotarClientError(SuotarErrorVariant::MalformedRequest),
840                format!(
841                    "A {} request repeats requestItemId `{}`.",
842                    endpoint.path(),
843                    item.request_item_id()
844                )
845            ));
846        }
847    }
848    Ok(items
849        .iter()
850        .map(|item| item.request_item_id().to_string())
851        .collect())
852}
853
854/// An empty array is a request-level error at the far end, so an empty batch is never sent and
855/// leaves no audit row.
856fn empty_batch_response<R>(endpoint: SuotarEndpoint) -> SuotarBatchResponse<R> {
857    SuotarBatchResponse {
858        endpoint,
859        items: Vec::new(),
860        missing_request_item_ids: Vec::new(),
861        unexpected_request_item_ids: Vec::new(),
862        http_status: 0,
863        duration: Duration::ZERO,
864        call_id: None,
865        raw_response: Arc::new(serde_json::Value::Array(Vec::new())),
866    }
867}
868
869/// Pairs the response against what was sent by `requestItemId`; order is not consulted.
870fn reconcile<R>(
871    endpoint: SuotarEndpoint,
872    sent_ids: Vec<String>,
873    items: Vec<SuotarResponseItem<R>>,
874    http_status: u16,
875    duration: Duration,
876    raw_response: Arc<serde_json::Value>,
877) -> SuotarBatchResponse<R> {
878    let sent: HashSet<&str> = sent_ids.iter().map(String::as_str).collect();
879    let answered: HashSet<&str> = items
880        .iter()
881        .map(|item| item.request_item_id.as_str())
882        .collect();
883
884    let missing_request_item_ids: Vec<String> = sent_ids
885        .iter()
886        .filter(|id| !answered.contains(id.as_str()))
887        .cloned()
888        .collect();
889    let unexpected_request_item_ids: Vec<String> = items
890        .iter()
891        .filter(|item| !sent.contains(item.request_item_id.as_str()))
892        .map(|item| item.request_item_id.clone())
893        .collect();
894
895    if !unexpected_request_item_ids.is_empty() {
896        warn!(
897            "Suotar {} answered with {} requestItemIds that were not sent; ignoring them.",
898            endpoint.path(),
899            unexpected_request_item_ids.len()
900        );
901    }
902    if !missing_request_item_ids.is_empty() && endpoint.creates_attainments() {
903        error!(
904            "Suotar {} left {} of {} items unanswered. Their attainments may or may not exist and they must not be re-sent.",
905            endpoint.path(),
906            missing_request_item_ids.len(),
907            sent_ids.len()
908        );
909    }
910    for item in &items {
911        if item.code == TRANSIENT_ITEM_CODE && !endpoint.carries_item_level_transient() {
912            warn!(
913                "Suotar {} returned an item-level `{TRANSIENT_ITEM_CODE}`, which its contract does not list.",
914                endpoint.path()
915            );
916        }
917    }
918
919    SuotarBatchResponse {
920        endpoint,
921        items,
922        missing_request_item_ids,
923        unexpected_request_item_ids,
924        http_status,
925        duration,
926        call_id: None,
927        raw_response,
928    }
929}
930
931#[derive(Debug, Deserialize)]
932struct RequestLevelErrorBody {
933    error: RequestLevelErrorDetail,
934}
935
936#[derive(Debug, Deserialize)]
937struct RequestLevelErrorDetail {
938    code: String,
939    message: String,
940}
941
942fn request_level_error(
943    endpoint: SuotarEndpoint,
944    http_status: u16,
945    detail: Option<&RequestLevelErrorDetail>,
946) -> UtilError {
947    let variant = match (http_status, detail.map(|detail| detail.code.as_str())) {
948        (401 | 403, _) => SuotarErrorVariant::Unauthorized,
949        (_, Some("unauthorized")) => SuotarErrorVariant::Unauthorized,
950        (_, Some("malformedRequest")) => SuotarErrorVariant::MalformedRequest,
951        (500..=599, _) => SuotarErrorVariant::ServerError,
952        _ => SuotarErrorVariant::RequestLevelError,
953    };
954    let detail = match detail {
955        Some(detail) => format!("`{}`: {}", detail.code, detail.message),
956        None => "no documented error body".to_string(),
957    };
958    let path = endpoint.path();
959    util_err!(
960        SuotarClientError(variant),
961        format!("Suotar {path} rejected the whole request with {http_status}, {detail}")
962    )
963}
964
965/// `is_connect` is the one case where the request provably never reached Suotar; everything else, a
966/// timeout above all, may have been processed.
967fn transport_variant(error: &reqwest::Error) -> SuotarErrorVariant {
968    if error.is_connect() || error.is_builder() {
969        SuotarErrorVariant::TransportNotDelivered
970    } else {
971        SuotarErrorVariant::TransportUnknown
972    }
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use serde_json::json;
979
980    fn person_items(ids: &[&str]) -> Vec<ResolvePersonRequestItem> {
981        ids.iter()
982            .map(|id| ResolvePersonRequestItem {
983                request_item_id: (*id).to_string(),
984                student_number: "012345678".to_string(),
985            })
986            .collect()
987    }
988
989    fn person_response(ids: &[&str]) -> Vec<SuotarResponseItem<PersonResult>> {
990        let items: Vec<serde_json::Value> = ids
991            .iter()
992            .map(|id| {
993                json!({
994                    "requestItemId": id,
995                    "status": "ok",
996                    "code": "personFound",
997                    "result": {
998                        "studentNumber": "012345678",
999                        "personId": "otm-person-id",
1000                        "firstNames": "Henrik Admin",
1001                        "lastName": "Nygren",
1002                    }
1003                })
1004            })
1005            .collect();
1006        serde_json::from_value(json!(items)).expect("person response")
1007    }
1008
1009    fn classified(http_status: u16, body: &str) -> UtilError {
1010        let detail = serde_json::from_str::<RequestLevelErrorBody>(body)
1011            .ok()
1012            .map(|parsed| parsed.error);
1013        request_level_error(
1014            SuotarEndpoint::ImportAttainments,
1015            http_status,
1016            detail.as_ref(),
1017        )
1018    }
1019
1020    fn reconciled(
1021        sent: &[&str],
1022        items: Vec<SuotarResponseItem<PersonResult>>,
1023    ) -> SuotarBatchResponse<PersonResult> {
1024        reconcile(
1025            SuotarEndpoint::ResolvePersons,
1026            sent.iter().map(|id| (*id).to_string()).collect(),
1027            items,
1028            200,
1029            Duration::ZERO,
1030            Arc::new(json!([])),
1031        )
1032    }
1033
1034    /// A leading slash on either side would silently drop the base's route prefix and 404 every
1035    /// call.
1036    #[test]
1037    fn every_endpoint_joins_onto_the_configured_base() {
1038        let client = SuotarClient::mock_for_test();
1039        let joined: Vec<String> = [
1040            SuotarEndpoint::ResolvePersons,
1041            SuotarEndpoint::ResolveEnrolments,
1042            SuotarEndpoint::ImportAttainments,
1043            SuotarEndpoint::VerifyAttainments,
1044            SuotarEndpoint::ProductAccessTokens,
1045            SuotarEndpoint::ListByCourse,
1046        ]
1047        .iter()
1048        .map(|endpoint| {
1049            client
1050                .api_base_url
1051                .join(endpoint.path())
1052                .expect("joins")
1053                .to_string()
1054        })
1055        .collect();
1056        assert_eq!(
1057            joined,
1058            vec![
1059                "http://project-331.local/api/v0/mock-suotar/persons/resolve-by-student-numbers",
1060                "http://project-331.local/api/v0/mock-suotar/enrolments/resolve",
1061                "http://project-331.local/api/v0/mock-suotar/attainments/import",
1062                "http://project-331.local/api/v0/mock-suotar/attainments/verify",
1063                "http://project-331.local/api/v0/mock-suotar/open-university-product-access-tokens/resolve",
1064                "http://project-331.local/api/v0/mock-suotar/enrolments/list-by-course",
1065            ]
1066        );
1067    }
1068
1069    #[test]
1070    fn a_request_batch_serializes_to_the_documented_shape() {
1071        let items = vec![ImportAttainmentRequestItem {
1072            request_item_id: "cr-11111111-1111-1111-1111-111111111111".to_string(),
1073            student_number: "012345678".to_string(),
1074            course_code: "TKT10001".to_string(),
1075            enrolment_id: "selected-enrolment-id".to_string(),
1076            attainment_date: NaiveDate::from_ymd_opt(2026, 5, 22).expect("valid date"),
1077            attainment_language: "fi".to_string(),
1078            grade_scale_id: "sis-hyl-hyv".to_string(),
1079            grade_id: "1".to_string(),
1080            credits: 5.0,
1081        }];
1082        assert_eq!(
1083            serde_json::to_value(&items).expect("serializes"),
1084            json!([{
1085                "requestItemId": "cr-11111111-1111-1111-1111-111111111111",
1086                "studentNumber": "012345678",
1087                "courseCode": "TKT10001",
1088                "enrolmentId": "selected-enrolment-id",
1089                "attainmentDate": "2026-05-22",
1090                "attainmentLanguage": "fi",
1091                "gradeScaleId": "sis-hyl-hyv",
1092                "gradeId": "1",
1093                "credits": 5.0
1094            }])
1095        );
1096    }
1097
1098    #[test]
1099    fn list_by_course_omits_an_absent_realisation_id() {
1100        let items = vec![ListByCourseRequestItem {
1101            request_item_id: "people-1".to_string(),
1102            course_code: "TKT10001".to_string(),
1103            course_unit_realisation_id: None,
1104        }];
1105        assert_eq!(
1106            serde_json::to_value(&items).expect("serializes"),
1107            json!([{ "requestItemId": "people-1", "courseCode": "TKT10001" }])
1108        );
1109    }
1110
1111    #[test]
1112    fn an_error_item_deserializes_without_a_result() {
1113        let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1114            "requestItemId": "b2",
1115            "status": "error",
1116            "code": "personNotFound",
1117            "error": { "message": "No Sisu person was found for the supplied student number." }
1118        }]))
1119        .expect("error item");
1120        assert_eq!(items[0].status, SuotarItemStatus::Error);
1121        assert!(items[0].result.is_none());
1122        assert_eq!(
1123            items[0].error.as_ref().map(|error| error.message.as_str()),
1124            Some("No Sisu person was found for the supplied student number.")
1125        );
1126    }
1127
1128    #[test]
1129    fn a_disclosed_sisu_timeout_carries_the_id_the_client_may_verify() {
1130        let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1131            serde_json::from_value(json!([{
1132                "requestItemId": "cr-1",
1133                "status": "error",
1134                "code": "sisuTimeout",
1135                "error": {
1136                    "message": "Sisu operation timed out; outcome is uncertain.",
1137                    "submittedAttainmentId": "hy-kur-1"
1138                }
1139            }]))
1140            .expect("disclosed timeout");
1141        assert_eq!(
1142            items[0]
1143                .error
1144                .as_ref()
1145                .and_then(|error| error.submitted_attainment_id.as_deref()),
1146            Some("hy-kur-1")
1147        );
1148    }
1149
1150    #[test]
1151    fn one_deserializer_covers_every_import_success_body() {
1152        let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1153            serde_json::from_value(json!([
1154                {
1155                    "requestItemId": "cr-1",
1156                    "status": "ok",
1157                    "code": "sent",
1158                    "result": {
1159                        "submittedAttainmentId": "hy-kur-1",
1160                        "submittedAttainmentType": "AssessmentItemAttainment"
1161                    }
1162                },
1163                {
1164                    "requestItemId": "cr-2",
1165                    "status": "ok",
1166                    "code": "registered",
1167                    "result": { "attainment": { "id": "final-id", "type": "CourseUnitAttainment" } }
1168                },
1169                {
1170                    "requestItemId": "cr-3",
1171                    "status": "ok",
1172                    "code": "duplicateAttainment",
1173                    "result": { "attainment": {
1174                        "id": "existing-id",
1175                        "type": "CourseUnitAttainment",
1176                        "state": "ATTAINED",
1177                        "attainmentDate": "2026-05-22",
1178                        "registrationDate": "2026-05-22",
1179                        "gradeScaleId": "sis-hyl-hyv",
1180                        "gradeId": "1"
1181                    } }
1182                },
1183                {
1184                    "requestItemId": "cr-4",
1185                    "status": "ok",
1186                    "code": "notImprovedAttainment",
1187                    "result": { "previousAttainment": {
1188                        "id": "existing-id",
1189                        "type": "CourseUnitAttainment",
1190                        "state": "ATTAINED",
1191                        "gradeScaleId": "sis-0-5",
1192                        "gradeId": "5",
1193                        "attainmentDate": "2026-03-01",
1194                        "registrationDate": "2026-03-05"
1195                    } }
1196                }
1197            ]))
1198            .expect("import successes");
1199
1200        let sent = items[0].result.as_ref().expect("sent result");
1201        assert_eq!(sent.submitted_attainment_id.as_deref(), Some("hy-kur-1"));
1202        let registered = items[1].result.as_ref().expect("registered result");
1203        assert_eq!(
1204            registered
1205                .attainment
1206                .as_ref()
1207                .map(|attainment| attainment.id.as_str()),
1208            Some("final-id")
1209        );
1210        let duplicate = items[2].result.as_ref().expect("duplicate result");
1211        assert_eq!(
1212            duplicate
1213                .attainment
1214                .as_ref()
1215                .and_then(|attainment| attainment.grade_id.as_deref()),
1216            Some("1")
1217        );
1218        let not_improved = items[3].result.as_ref().expect("not improved result");
1219        assert_eq!(
1220            not_improved
1221                .previous_attainment
1222                .as_ref()
1223                .map(|attainment| attainment.id.as_str()),
1224            Some("existing-id")
1225        );
1226    }
1227
1228    #[test]
1229    fn an_unknown_code_does_not_fail_deserialization() {
1230        let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1231            "requestItemId": "a1",
1232            "status": "error",
1233            "code": "somethingSuotarAddedLater",
1234            "error": { "message": "..." }
1235        }]))
1236        .expect("unknown code");
1237        assert_eq!(items[0].code, "somethingSuotarAddedLater");
1238    }
1239
1240    #[test]
1241    fn items_are_matched_by_request_item_id_not_position() {
1242        let response = reconciled(&["a1", "b2", "c3"], person_response(&["c3", "a1", "b2"]));
1243        assert!(response.missing_request_item_ids.is_empty());
1244        assert!(response.unexpected_request_item_ids.is_empty());
1245        assert_eq!(
1246            response.item("b2").map(|item| item.code.as_str()),
1247            Some("personFound")
1248        );
1249    }
1250
1251    #[test]
1252    fn an_unanswered_item_is_reported_rather_than_paired_with_a_neighbour() {
1253        let response = reconciled(&["a1", "b2", "c3"], person_response(&["c3", "a1"]));
1254        assert_eq!(response.missing_request_item_ids, vec!["b2".to_string()]);
1255        assert!(response.item("b2").is_none());
1256        assert!(response.item("c3").is_some());
1257    }
1258
1259    #[test]
1260    fn an_item_id_that_was_never_sent_is_reported_and_kept_out_of_the_way() {
1261        let response = reconciled(&["a1"], person_response(&["a1", "z9"]));
1262        assert_eq!(response.unexpected_request_item_ids, vec!["z9".to_string()]);
1263        assert!(response.missing_request_item_ids.is_empty());
1264    }
1265
1266    #[test]
1267    fn a_repeated_request_item_id_is_refused_before_the_request_is_built() {
1268        let error = check_batch(SuotarEndpoint::ResolvePersons, &person_items(&["a1", "a1"]))
1269            .expect_err("duplicate ids");
1270        assert!(error.message().contains("repeats requestItemId `a1`"));
1271    }
1272
1273    #[test]
1274    fn a_batch_over_the_endpoints_size_is_refused_before_the_request_is_built() {
1275        let items: Vec<ResolvePersonRequestItem> = (0..101)
1276            .map(|index| ResolvePersonRequestItem {
1277                request_item_id: format!("cr-{index}"),
1278                student_number: "012345678".to_string(),
1279            })
1280            .collect();
1281        for (endpoint, size) in [
1282            (SuotarEndpoint::ListByCourse, 10),
1283            (SuotarEndpoint::ImportAttainments, 25),
1284            (SuotarEndpoint::ResolvePersons, 50),
1285            (SuotarEndpoint::VerifyAttainments, 100),
1286        ] {
1287            assert_eq!(endpoint.max_batch_size(), size, "{endpoint:?}");
1288            assert!(
1289                check_batch(endpoint, &items[..size]).is_ok(),
1290                "{endpoint:?}"
1291            );
1292            assert!(
1293                check_batch(endpoint, &items[..size + 1]).is_err(),
1294                "{endpoint:?}"
1295            );
1296        }
1297    }
1298
1299    #[test]
1300    fn the_documented_request_level_bodies_classify() {
1301        let unauthorized = classified(
1302            401,
1303            r#"{"error":{"code":"unauthorized","message":"Missing or invalid credentials."}}"#,
1304        );
1305        assert!(matches!(
1306            unauthorized.error_type(),
1307            UtilErrorType::SuotarClientError(SuotarErrorVariant::Unauthorized)
1308        ));
1309
1310        let malformed = classified(
1311            400,
1312            r#"{"error":{"code":"malformedRequest","message":"Request body is not valid JSON or has the wrong top-level shape."}}"#,
1313        );
1314        assert!(matches!(
1315            malformed.error_type(),
1316            UtilErrorType::SuotarClientError(SuotarErrorVariant::MalformedRequest)
1317        ));
1318
1319        let server = classified(
1320            503,
1321            r#"{"error":{"code":"sisuTemporarilyUnavailable","message":"Sisu was temporarily unavailable."}}"#,
1322        );
1323        assert!(matches!(
1324            server.error_type(),
1325            UtilErrorType::SuotarClientError(SuotarErrorVariant::ServerError)
1326        ));
1327
1328        let bodyless = classified(502, "<html>");
1329        assert!(matches!(
1330            bodyless.error_type(),
1331            UtilErrorType::SuotarClientError(SuotarErrorVariant::ServerError)
1332        ));
1333    }
1334
1335    #[test]
1336    fn only_the_failures_that_never_reached_suotar_are_safe_to_resend() {
1337        assert!(!SuotarErrorVariant::TransportNotDelivered.outcome_may_have_landed());
1338        assert!(!SuotarErrorVariant::Unauthorized.outcome_may_have_landed());
1339        assert!(!SuotarErrorVariant::MalformedRequest.outcome_may_have_landed());
1340        assert!(!SuotarErrorVariant::RequestLevelError.outcome_may_have_landed());
1341        assert!(SuotarErrorVariant::TransportUnknown.outcome_may_have_landed());
1342        assert!(SuotarErrorVariant::ServerError.outcome_may_have_landed());
1343        assert!(SuotarErrorVariant::Deserialization.outcome_may_have_landed());
1344    }
1345}