headless_lms_server/controllers/mock_suotar/
wire.rs1use 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
20pub 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
102pub 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 "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 "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 #[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}