Skip to main content

headless_lms_server/controllers/mock_suotar/
wire.rs

1//! The mock's half of the six contract endpoints: the client's own request and response types,
2//! re-exported so a mock body that the client cannot read is a compile error, plus the shapes and
3//! message strings that exist only on the answering side.
4//!
5//! Every endpoint takes a top-level JSON array and answers with one item per request item, in order.
6//! Per-item outcomes are HTTP 200; only request-level failures are 4xx/5xx.
7
8use headless_lms_utils::services::suotar::SuotarEndpoint;
9pub use headless_lms_utils::services::suotar::{
10    CreditRange, DatePeriod, EnrolmentResolutionResult, EnrolmentsListedResult, ExistingAttainment,
11    ImportAttainmentRequestItem, ImportAttainmentResult, ListByCourseRequestItem, ListedEnrolment,
12    ListedPerson, LocalizedName, PersonResult, ProductAccessTokenRequestItem,
13    ProductAccessTokenResult, ResolveEnrolmentRequestItem, ResolvePersonRequestItem,
14    SuotarAttainment, SuotarEnrolment, SuotarItemError, SuotarItemStatus, SuotarResponseItem,
15    VerifyAttainmentRequestItem, VerifyAttainmentResult,
16};
17
18use crate::prelude::*;
19
20/// A request's items are all one endpoint's shape, but the pipeline also carries fault-shaped items
21/// and logs them, so the payload is erased once the per-item logic has built it in its typed form.
22pub type ErasedResponseItem = SuotarResponseItem<serde_json::Value>;
23
24pub fn erase<R: Serialize>(item: SuotarResponseItem<R>) -> ErasedResponseItem {
25    ErasedResponseItem {
26        request_item_id: item.request_item_id,
27        status: item.status,
28        code: item.code,
29        result: item
30            .result
31            .map(|result| serde_json::to_value(result).unwrap_or(serde_json::Value::Null)),
32        error: item.error,
33    }
34}
35
36pub fn ok_item<R>(request_item_id: &str, code: &str, result: R) -> SuotarResponseItem<R> {
37    SuotarResponseItem {
38        request_item_id: request_item_id.to_string(),
39        status: SuotarItemStatus::Ok,
40        code: code.to_string(),
41        result: Some(result),
42        error: None,
43    }
44}
45
46pub fn error_item<R>(
47    endpoint: SuotarEndpoint,
48    request_item_id: &str,
49    code: &str,
50) -> SuotarResponseItem<R> {
51    error_item_with_message(request_item_id, code, canonical_message(endpoint, code))
52}
53
54pub fn error_item_with_message<R>(
55    request_item_id: &str,
56    code: &str,
57    message: String,
58) -> SuotarResponseItem<R> {
59    SuotarResponseItem {
60        request_item_id: request_item_id.to_string(),
61        status: SuotarItemStatus::Error,
62        code: code.to_string(),
63        result: None,
64        error: Some(SuotarItemError {
65            message,
66            submitted_attainment_id: None,
67        }),
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct RequestLevelErrorBody {
73    pub code: String,
74    pub message: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct RequestLevelError {
79    pub error: RequestLevelErrorBody,
80}
81
82impl RequestLevelError {
83    pub fn new(endpoint: SuotarEndpoint, code: &str) -> Self {
84        Self {
85            error: RequestLevelErrorBody {
86                code: code.to_string(),
87                message: canonical_message(endpoint, code),
88            },
89        }
90    }
91
92    pub fn with_message(code: &str, message: String) -> Self {
93        Self {
94            error: RequestLevelErrorBody {
95                code: code.to_string(),
96                message,
97            },
98        }
99    }
100}
101
102/// `sisuTemporarilyUnavailable` is worded differently per endpoint, reproduced here so a client
103/// keying off `message` instead of `code` breaks.
104pub fn canonical_message(endpoint: SuotarEndpoint, code: &str) -> String {
105    if code == "sisuTemporarilyUnavailable" {
106        return match endpoint {
107            SuotarEndpoint::VerifyAttainments => {
108                "Sisu was temporarily unavailable during verification."
109            }
110            SuotarEndpoint::ProductAccessTokens => {
111                "Suotar could not fetch the Open University product access token from Sisu."
112            }
113            SuotarEndpoint::ListByCourse => "Suotar could not serve the list of enrolled people.",
114            _ => "Sisu was temporarily unavailable.",
115        }
116        .to_string();
117    }
118    match code {
119        "personNotFound" => "No Sisu person was found for the supplied student number.",
120        "courseCodeNotFound" => "Course code could not be resolved in Sisu.",
121        "enrolmentNotFound" => {
122            "No ENROLLED Sisu enrolment was found for this student and course code."
123        }
124        // TODO: Suotar has not given wording for this code; the proposal only names it.
125        "enrolmentNotAccepted" => "The student's Sisu enrolment has not been accepted.",
126        "studyRightNotValid" => "Study right cannot support the attainment.",
127        "sisuTimeout" => "Sisu operation timed out; outcome is uncertain.",
128        "notRegistered" => {
129            "No final or partial Sisu registration evidence was found for the submitted attainment id."
130        }
131        "misregistered" => {
132            "A previously registered attainment has been marked misregistered in Sisu."
133        }
134        "productAccessTokenNotFound" => {
135            "No access token was found for the supplied Open University product id."
136        }
137        "unauthorized" => "Missing or invalid credentials.",
138        "malformedRequest" => "Request body is not valid JSON or has the wrong top-level shape.",
139        // TODO: Suotar has not given wording for the five import validation codes below.
140        "invalidGradeForGradeScale" => "The grade is not valid for the enrolment's grade scale.",
141        "courseNotAllowed" => "Attainments may not be imported for this course.",
142        "invalidCredits" => "The credits are outside the range the enrolment allows.",
143        "acceptorNotFound" => "No acceptor was found for the course unit realisation.",
144        "sisuValidationFailed" => "Sisu rejected the attainment as invalid.",
145        "internalError" => "Suotar encountered an internal error.",
146        _ => "Suotar returned an unspecified outcome.",
147    }
148    .to_string()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// The proposal's own example bodies, verbatim.
156    #[test]
157    fn the_proposals_example_request_bodies_deserialize() {
158        let persons: Vec<ResolvePersonRequestItem> = serde_json::from_str(
159            r#"[{ "requestItemId": "person-1", "studentNumber": "012345678" }]"#,
160        )
161        .expect("resolve-persons example");
162        assert_eq!(persons[0].student_number, "012345678");
163
164        let enrolments: Vec<ResolveEnrolmentRequestItem> = serde_json::from_str(
165            r#"[{ "requestItemId": "enrolment-1", "studentNumber": "012345678", "courseCode": "TKT10001" }]"#,
166        )
167        .expect("resolve-enrolments example");
168        assert_eq!(enrolments[0].course_code, "TKT10001");
169
170        let imports: Vec<ImportAttainmentRequestItem> = serde_json::from_str(
171            r#"[{
172                "requestItemId": "moocfi-completion-12345",
173                "studentNumber": "012345678",
174                "courseCode": "TKT10001",
175                "enrolmentId": "selected-enrolment-id",
176                "attainmentDate": "2026-05-22",
177                "attainmentLanguage": "fi",
178                "gradeScaleId": "sis-hyl-hyv",
179                "gradeId": "1",
180                "credits": 5
181            }]"#,
182        )
183        .expect("import example");
184        assert_eq!(imports[0].credits, 5.0);
185
186        let verifies: Vec<VerifyAttainmentRequestItem> = serde_json::from_str(
187            r#"[{ "requestItemId": "verify-1", "submittedAttainmentId": "hy-kur-1" }]"#,
188        )
189        .expect("verify example");
190        assert_eq!(verifies[0].submitted_attainment_id, "hy-kur-1");
191
192        let tokens: Vec<ProductAccessTokenRequestItem> = serde_json::from_str(
193            r#"[{ "requestItemId": "token-1", "openUniversityProductId": "otm-product" }]"#,
194        )
195        .expect("product access token example");
196        assert_eq!(tokens[0].open_university_product_id, "otm-product");
197
198        let listings: Vec<ListByCourseRequestItem> = serde_json::from_str(
199            r#"[{ "requestItemId": "people-1", "courseCode": "TKT10001", "courseUnitRealisationId": "hy-opt-cur-1" }]"#,
200        )
201        .expect("list-by-course example");
202        assert_eq!(
203            listings[0].course_unit_realisation_id.as_deref(),
204            Some("hy-opt-cur-1")
205        );
206    }
207
208    #[test]
209    fn a_per_item_error_serializes_to_the_documented_shape() {
210        let item: ErasedResponseItem =
211            error_item(SuotarEndpoint::ResolvePersons, "b2", "personNotFound");
212        assert_eq!(
213            serde_json::to_value(&item).expect("serializes"),
214            serde_json::json!({
215                "requestItemId": "b2",
216                "status": "error",
217                "code": "personNotFound",
218                "error": { "message": "No Sisu person was found for the supplied student number." }
219            })
220        );
221    }
222}