1use std::sync::LazyLock;
6
7use regex::{Captures, Regex};
8use serde_json::{Value, json};
9use utoipa::ToSchema;
10
11use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
12use crate::prelude::*;
13use crate::suotar_api_calls::SuotarEndpoint;
14
15pub const REDACTED: &str = "[redacted]";
17
18const REDACTED_KEYS: &[&str] = &[
21 "studentnumber",
22 "firstnames",
23 "lastname",
24 "fullname",
25 "primaryemail",
26 "secondaryemail",
27 "email",
28 "emailedto",
29 "accesstoken",
30 "personid",
31 "sisupersonid",
32];
33
34const NEVER_SCANNED_KEYS: &[&str] = &[
39 "requestitemid",
40 "code",
41 "coursecode",
42 "gradescaleid",
43 "gradeid",
44 "credits",
45 "attainmentdate",
46 "attainmentlanguage",
47 "submittedattainmentid",
48 "attainmentid",
49 "sisuattainmentid",
50 "courseunitrealisationid",
51 "openuniversityproductid",
52];
53
54static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
55 Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").expect("hardcoded regex")
56});
57
58static STUDENT_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
65 Regex::new(
66 r"(?P<uuid>\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b)|(?P<prefixed>\b[A-Za-z][A-Za-z0-9-]*-[0-9]{6,12}\b)|(?P<digits>\b[0-9]{6,12}\b)",
67 )
68 .expect("hardcoded regex")
69});
70
71pub fn scrub_suotar_body(value: &Value) -> Value {
82 scrub(value)
83}
84
85enum KeyPolicy {
86 FullyRedact,
88 NeverScan,
90 ScanFreeText,
92}
93
94fn key_policy(key: &str) -> KeyPolicy {
95 let normalized = normalize_key(key);
96 if REDACTED_KEYS.contains(&normalized.as_str()) {
97 KeyPolicy::FullyRedact
98 } else if NEVER_SCANNED_KEYS.contains(&normalized.as_str()) {
99 KeyPolicy::NeverScan
100 } else {
101 KeyPolicy::ScanFreeText
102 }
103}
104
105fn scrub(value: &Value) -> Value {
106 match value {
107 Value::Object(map) => Value::Object(
108 map.iter()
109 .map(|(key, child)| {
110 let scrubbed = match key_policy(key) {
111 KeyPolicy::FullyRedact => json!(REDACTED),
112 KeyPolicy::NeverScan => keep_scalars(child),
113 KeyPolicy::ScanFreeText => scrub(child),
114 };
115 (key.clone(), scrubbed)
116 })
117 .collect(),
118 ),
119 Value::Array(items) => Value::Array(items.iter().map(scrub).collect()),
120 Value::String(text) => Value::String(scrub_free_text(text)),
121 other => other.clone(),
122 }
123}
124
125fn keep_scalars(value: &Value) -> Value {
128 match value {
129 Value::Object(_) => scrub(value),
130 Value::Array(items) => Value::Array(items.iter().map(keep_scalars).collect()),
131 other => other.clone(),
132 }
133}
134
135fn normalize_key(key: &str) -> String {
136 key.chars()
137 .filter(|c| c.is_ascii_alphanumeric())
138 .flat_map(|c| c.to_lowercase())
139 .collect()
140}
141
142fn scrub_free_text(text: &str) -> String {
143 let without_emails = EMAIL_RE.replace_all(text, REDACTED);
144 STUDENT_NUMBER_RE
145 .replace_all(&without_emails, |captures: &Captures| {
146 if captures.name("digits").is_some() {
148 REDACTED.to_string()
149 } else {
150 captures[0].to_string()
151 }
152 })
153 .into_owned()
154}
155
156pub fn scrub_text(text: &str) -> String {
158 scrub_free_text(text)
159}
160
161pub fn suotar_exchange_details(request: Option<&Value>, response: Option<&Value>) -> Value {
165 let mut details = serde_json::Map::new();
166 if let Some(request) = request {
167 details.insert("request".to_string(), scrub_suotar_body(request));
168 }
169 if let Some(response) = response {
170 details.insert("response".to_string(), scrub_suotar_body(response));
171 }
172 Value::Object(details)
173}
174
175#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
176#[sqlx(
177 type_name = "credit_registration_event_kind",
178 rename_all = "snake_case"
179)]
180#[serde(rename_all = "snake_case")]
181pub enum CreditRegistrationEventKind {
182 Created,
183 StateChanged,
184 SuotarResponse,
185 RetryScheduled,
186 AdminAction,
187 StudentAction,
188 Cancelled,
189}
190
191#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
192pub struct CreditRegistrationEvent {
193 pub id: Uuid,
194 pub created_at: DateTime<Utc>,
195 pub updated_at: DateTime<Utc>,
196 pub deleted_at: Option<DateTime<Utc>>,
197 pub credit_registration_id: Uuid,
198 pub kind: CreditRegistrationEventKind,
199 pub from_state: Option<CreditRegistrationState>,
200 pub to_state: Option<CreditRegistrationState>,
201 pub error_code: Option<CreditRegistrationErrorCode>,
202 pub message: Option<String>,
203 pub suotar_api_call_id: Option<Uuid>,
204 pub actor_user_id: Option<Uuid>,
205 pub details: Option<Value>,
206}
207
208#[derive(Debug, Clone, PartialEq)]
209pub struct NewCreditRegistrationEvent {
210 pub credit_registration_id: Uuid,
211 pub kind: CreditRegistrationEventKind,
212 pub from_state: Option<CreditRegistrationState>,
213 pub to_state: Option<CreditRegistrationState>,
214 pub error_code: Option<CreditRegistrationErrorCode>,
215 pub message: Option<String>,
216 pub suotar_api_call_id: Option<Uuid>,
217 pub actor_user_id: Option<Uuid>,
218 pub details: Option<Value>,
220}
221
222impl NewCreditRegistrationEvent {
223 pub fn new(credit_registration_id: Uuid, kind: CreditRegistrationEventKind) -> Self {
224 Self {
225 credit_registration_id,
226 kind,
227 from_state: None,
228 to_state: None,
229 error_code: None,
230 message: None,
231 suotar_api_call_id: None,
232 actor_user_id: None,
233 details: None,
234 }
235 }
236}
237
238pub async fn insert(
241 conn: &mut PgConnection,
242 new: &NewCreditRegistrationEvent,
243) -> ModelResult<Uuid> {
244 let res = sqlx::query!(
245 r#"
246INSERT INTO credit_registration_events (
247 credit_registration_id,
248 kind,
249 from_state,
250 to_state,
251 error_code,
252 message,
253 suotar_api_call_id,
254 actor_user_id,
255 details
256 )
257VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
258RETURNING id
259 "#,
260 new.credit_registration_id,
261 new.kind as CreditRegistrationEventKind,
262 new.from_state as Option<CreditRegistrationState>,
263 new.to_state as Option<CreditRegistrationState>,
264 new.error_code as Option<CreditRegistrationErrorCode>,
265 new.message,
266 new.suotar_api_call_id,
267 new.actor_user_id,
268 new.details,
269 )
270 .fetch_one(conn)
271 .await?;
272 Ok(res.id)
273}
274
275pub async fn insert_batch(
279 conn: &mut PgConnection,
280 events: &[NewCreditRegistrationEvent],
281) -> ModelResult<()> {
282 if events.is_empty() {
283 return Ok(());
284 }
285 let ids: Vec<Uuid> = events.iter().map(|e| e.credit_registration_id).collect();
286 let kinds: Vec<CreditRegistrationEventKind> = events.iter().map(|e| e.kind).collect();
287 let from_states: Vec<Option<CreditRegistrationState>> =
288 events.iter().map(|e| e.from_state).collect();
289 let to_states: Vec<Option<CreditRegistrationState>> =
290 events.iter().map(|e| e.to_state).collect();
291 let error_codes: Vec<Option<CreditRegistrationErrorCode>> =
292 events.iter().map(|e| e.error_code).collect();
293 let messages: Vec<Option<String>> = events.iter().map(|e| e.message.clone()).collect();
294 let call_ids: Vec<Option<Uuid>> = events.iter().map(|e| e.suotar_api_call_id).collect();
295 let actors: Vec<Option<Uuid>> = events.iter().map(|e| e.actor_user_id).collect();
296 let details: Vec<Option<Value>> = events.iter().map(|e| e.details.clone()).collect();
297 sqlx::query!(
298 r#"
299INSERT INTO credit_registration_events (
300 credit_registration_id,
301 kind,
302 from_state,
303 to_state,
304 error_code,
305 message,
306 suotar_api_call_id,
307 actor_user_id,
308 details
309 )
310SELECT *
311FROM UNNEST(
312 $1::uuid [],
313 $2::credit_registration_event_kind [],
314 $3::credit_registration_state [],
315 $4::credit_registration_state [],
316 $5::credit_registration_error_code [],
317 $6::text [],
318 $7::uuid [],
319 $8::uuid [],
320 $9::jsonb []
321 )
322 "#,
323 &ids,
324 &kinds as &[CreditRegistrationEventKind],
325 &from_states as &[Option<CreditRegistrationState>],
326 &to_states as &[Option<CreditRegistrationState>],
327 &error_codes as &[Option<CreditRegistrationErrorCode>],
328 &messages as &[Option<String>],
329 &call_ids as &[Option<Uuid>],
330 &actors as &[Option<Uuid>],
331 &details as &[Option<Value>],
332 )
333 .execute(conn)
334 .await?;
335 Ok(())
336}
337
338pub async fn insert_many(
339 conn: &mut PgConnection,
340 credit_registration_ids: &[Uuid],
341 kind: CreditRegistrationEventKind,
342 actor_user_id: Option<Uuid>,
343 message: Option<&str>,
344) -> ModelResult<()> {
345 if credit_registration_ids.is_empty() {
346 return Ok(());
347 }
348 sqlx::query!(
349 r#"
350INSERT INTO credit_registration_events (credit_registration_id, kind, actor_user_id, message)
351SELECT id, $2, $3, $4
352FROM UNNEST($1::uuid []) AS id
353 "#,
354 credit_registration_ids,
355 kind as CreditRegistrationEventKind,
356 actor_user_id,
357 message,
358 )
359 .execute(conn)
360 .await?;
361 Ok(())
362}
363
364pub async fn get_by_registration_id(
366 conn: &mut PgConnection,
367 credit_registration_id: Uuid,
368) -> ModelResult<Vec<CreditRegistrationEvent>> {
369 let res = sqlx::query_as!(
370 CreditRegistrationEvent,
371 r#"
372SELECT *
373FROM credit_registration_events
374WHERE credit_registration_id = $1
375 AND deleted_at IS NULL
376ORDER BY created_at DESC
377 "#,
378 credit_registration_id
379 )
380 .fetch_all(conn)
381 .await?;
382 Ok(res)
383}
384
385pub async fn get_by_suotar_api_call_id(
388 conn: &mut PgConnection,
389 suotar_api_call_id: Uuid,
390) -> ModelResult<Vec<CreditRegistrationEvent>> {
391 let res = sqlx::query_as!(
392 CreditRegistrationEvent,
393 r#"
394SELECT *
395FROM credit_registration_events
396WHERE suotar_api_call_id = $1
397 AND deleted_at IS NULL
398ORDER BY created_at
399 "#,
400 suotar_api_call_id
401 )
402 .fetch_all(conn)
403 .await?;
404 Ok(res)
405}
406
407#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
412pub struct NotImprovedAttainment {
413 pub grade_id: Option<String>,
414 pub grade_scale_id: Option<String>,
417}
418
419pub async fn get_not_improved_attainment(
422 conn: &mut PgConnection,
423 credit_registration_id: Uuid,
424) -> ModelResult<Option<NotImprovedAttainment>> {
425 let found = sqlx::query_scalar!(
426 r#"
427SELECT details #> '{response,result,previousAttainment}' AS "attainment!"
428FROM credit_registration_events
429WHERE credit_registration_id = $1
430 AND to_state = 'not_improved'
431 AND details #> '{response,result,previousAttainment}' IS NOT NULL
432 AND deleted_at IS NULL
433ORDER BY created_at DESC
434LIMIT 1
435 "#,
436 credit_registration_id
437 )
438 .fetch_optional(conn)
439 .await?;
440 Ok(found.map(|attainment| NotImprovedAttainment {
441 grade_id: string_field(&attainment, "gradeId"),
442 grade_scale_id: string_field(&attainment, "gradeScaleId"),
443 }))
444}
445
446fn string_field(value: &Value, key: &str) -> Option<String> {
447 Some(value.get(key)?.as_str()?.to_string())
448}
449
450#[derive(Debug, Clone, PartialEq, Default)]
452pub struct SuotarItemOutcomeTotals {
453 pub item_count: i64,
454 pub sisu_unavailable_count: i64,
456 pub last_sisu_unavailable_at: Option<DateTime<Utc>>,
457}
458
459pub async fn count_item_outcomes_since(
462 conn: &mut PgConnection,
463 since: DateTime<Utc>,
464) -> ModelResult<SuotarItemOutcomeTotals> {
465 let res = sqlx::query_as!(
466 SuotarItemOutcomeTotals,
467 r#"
468SELECT COUNT(*) AS "item_count!",
469 COUNT(*) FILTER (
470 WHERE error_code IN ('sisu_temporarily_unavailable', 'sisu_timeout')
471 ) AS "sisu_unavailable_count!",
472 MAX(created_at) FILTER (
473 WHERE error_code IN ('sisu_temporarily_unavailable', 'sisu_timeout')
474 ) AS "last_sisu_unavailable_at"
475FROM credit_registration_events
476WHERE kind = 'suotar_response'
477 AND created_at >= $1
478 AND deleted_at IS NULL
479 "#,
480 since,
481 )
482 .fetch_one(conn)
483 .await?;
484 Ok(res)
485}
486
487#[derive(Debug, Clone, PartialEq)]
489pub struct ErrorCodeWindowCounts {
490 pub error_code: CreditRegistrationErrorCode,
491 pub current_count: i64,
492 pub previous_count: i64,
494 pub user_count: i64,
495 pub course_count: i64,
496 pub first_seen_at: Option<DateTime<Utc>>,
497 pub last_seen_at: Option<DateTime<Utc>>,
498 pub endpoints: Vec<SuotarEndpoint>,
500}
501
502pub async fn get_error_code_counts_for_window(
507 conn: &mut PgConnection,
508 window_secs: i64,
509) -> ModelResult<Vec<ErrorCodeWindowCounts>> {
510 let res = sqlx::query_as!(
511 ErrorCodeWindowCounts,
512 r#"
513SELECT e.error_code AS "error_code!: CreditRegistrationErrorCode",
514 COUNT(*) FILTER (
515 WHERE e.created_at > now() - MAKE_INTERVAL(secs => $1::double precision)
516 ) AS "current_count!",
517 COUNT(*) FILTER (
518 WHERE e.created_at <= now() - MAKE_INTERVAL(secs => $1::double precision)
519 ) AS "previous_count!",
520 COUNT(DISTINCT cr.user_id) AS "user_count!",
521 COUNT(DISTINCT cr.course_id) AS "course_count!",
522 MIN(e.created_at) AS "first_seen_at",
523 MAX(e.created_at) AS "last_seen_at",
524 COALESCE(
525 ARRAY_AGG(DISTINCT call.endpoint) FILTER (
526 WHERE call.endpoint IS NOT NULL
527 ),
528 '{}'
529 ) AS "endpoints!: Vec<SuotarEndpoint>"
530FROM credit_registration_events e
531 JOIN credit_registrations cr ON cr.id = e.credit_registration_id
532 LEFT JOIN suotar_api_calls call ON call.id = e.suotar_api_call_id
533 AND call.deleted_at IS NULL
534WHERE e.error_code IS NOT NULL
535 AND e.created_at > now() - MAKE_INTERVAL(secs => $1::double precision * 2)
536 AND e.deleted_at IS NULL
537 AND cr.deleted_at IS NULL
538GROUP BY e.error_code
539ORDER BY 2 DESC
540 "#,
541 window_secs as f64,
542 )
543 .fetch_all(conn)
544 .await?;
545 Ok(res)
546}
547
548pub async fn get_ids_with_several_submitted_attainments(
554 conn: &mut PgConnection,
555 limit: i64,
556) -> ModelResult<Vec<Uuid>> {
557 let res = sqlx::query_scalar!(
558 r#"
559SELECT credit_registration_id
560FROM credit_registration_events
561WHERE details #>> '{response,submittedAttainmentId}' IS NOT NULL
562 AND deleted_at IS NULL
563GROUP BY credit_registration_id
564HAVING COUNT(
565 DISTINCT details #>> '{response,submittedAttainmentId}'
566 ) > 1
567LIMIT $1
568 "#,
569 limit,
570 )
571 .fetch_all(conn)
572 .await?;
573 Ok(res)
574}
575
576pub async fn get_recent_by_kind(
577 conn: &mut PgConnection,
578 kind: CreditRegistrationEventKind,
579 limit: i64,
580) -> ModelResult<Vec<CreditRegistrationEvent>> {
581 let res = sqlx::query_as!(
582 CreditRegistrationEvent,
583 r#"
584SELECT *
585FROM credit_registration_events
586WHERE kind = $1
587 AND deleted_at IS NULL
588ORDER BY created_at DESC
589LIMIT $2
590 "#,
591 kind as CreditRegistrationEventKind,
592 limit,
593 )
594 .fetch_all(conn)
595 .await?;
596 Ok(res)
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn redacts_by_key_name_but_keeps_the_key() {
605 let scrubbed = scrub_suotar_body(&json!({
606 "studentNumber": "012345678",
607 "firstNames": "Aada Maria",
608 "lastName": "Virtanen",
609 "primaryEmail": "aada@helsinki.fi",
610 "accessToken": "abc123",
611 "personId": "hy-hlo-1",
612 }));
613 assert_eq!(
614 scrubbed,
615 json!({
616 "studentNumber": REDACTED,
617 "firstNames": REDACTED,
618 "lastName": REDACTED,
619 "primaryEmail": REDACTED,
620 "accessToken": REDACTED,
621 "personId": REDACTED,
622 })
623 );
624 }
625
626 #[test]
627 fn keeps_the_fields_debugging_needs() {
628 let body = json!({
630 "requestItemId": "cr-2a4b0d6e-0000-4000-8000-000000000001",
631 "code": "sent",
632 "status": "ok",
633 "courseCode": "AYTKT21018",
634 "enrolmentId": "hy-CUR-135176012",
635 "gradeScaleId": "sis-0-5",
636 "gradeId": "4",
637 "credits": 5.0,
638 "attainmentDate": "2026-07-30",
639 "attainmentLanguage": "fi",
640 "submittedAttainmentId": "hy-att-1",
641 });
642 assert_eq!(scrub_suotar_body(&body), body);
643 }
644
645 #[test]
646 fn redacts_recursively_through_objects_and_arrays() {
647 let scrubbed = scrub_suotar_body(&json!({
648 "items": [
649 { "person": { "studentNumber": "012345678" }, "code": "ok" },
650 { "person": { "studentNumber": "012345679" }, "code": "ok" },
651 ]
652 }));
653 assert_eq!(
654 scrubbed,
655 json!({
656 "items": [
657 { "person": { "studentNumber": REDACTED }, "code": "ok" },
658 { "person": { "studentNumber": REDACTED }, "code": "ok" },
659 ]
660 })
661 );
662 }
663
664 #[test]
665 fn matches_keys_case_insensitively_and_across_naming_styles() {
666 let scrubbed = scrub_suotar_body(&json!({
667 "STUDENT_NUMBER": "012345678",
668 "Student-Number": "012345678",
669 "emailedTo": "aada@helsinki.fi",
670 }));
671 assert_eq!(
672 scrubbed,
673 json!({
674 "STUDENT_NUMBER": REDACTED,
675 "Student-Number": REDACTED,
676 "emailedTo": REDACTED,
677 })
678 );
679 }
680
681 #[test]
682 fn value_scan_catches_identifiers_quoted_in_free_text() {
683 let scrubbed = scrub_suotar_body(&json!({
685 "message": "Person 012345678 (aada@helsinki.fi) has no accepted enrolment",
686 }));
687 assert_eq!(
688 scrubbed,
689 json!({
690 "message": format!("Person {REDACTED} ({REDACTED}) has no accepted enrolment"),
691 })
692 );
693 }
694
695 #[test]
696 fn a_never_scanned_key_covers_the_ids_in_a_list_under_it() {
697 let body = json!({ "courseUnitRealisationId": ["hy-CUR-135176012", "hy-CUR-135176013"] });
698 assert_eq!(scrub_suotar_body(&body), body);
699
700 let mixed = json!({
702 "code": ["hy-CUR-135176012", { "message": "Person 012345678 not found" }],
703 });
704 assert_eq!(
705 scrub_suotar_body(&mixed),
706 json!({
707 "code": [
708 "hy-CUR-135176012",
709 { "message": format!("Person {REDACTED} not found") },
710 ],
711 })
712 );
713 }
714
715 #[test]
716 fn a_redacted_key_takes_its_whole_value_with_it() {
717 let scrubbed = scrub_suotar_body(&json!({
719 "firstNames": ["Aada", "Maria"],
720 "personId": { "value": "hy-hlo-1" },
721 }));
722 assert_eq!(
723 scrubbed,
724 json!({ "firstNames": REDACTED, "personId": REDACTED })
725 );
726 }
727
728 #[test]
729 fn value_scan_reaches_inside_a_never_scanned_key() {
730 let scrubbed = scrub_suotar_body(&json!({
732 "items": [{
733 "status": {
734 "code": "personNotFound",
735 "message": "Person 012345678 (aada@helsinki.fi) not found",
736 },
737 }],
738 }));
739 assert_eq!(
740 scrubbed,
741 json!({
742 "items": [{
743 "status": {
744 "code": "personNotFound",
745 "message": format!("Person {REDACTED} ({REDACTED}) not found"),
746 },
747 }],
748 })
749 );
750 }
751
752 #[test]
753 fn value_scan_keeps_request_item_ids_quoted_in_free_text_whole() {
754 let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
756 assert_eq!(scrub_suotar_body(&body), body);
757
758 let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
760 assert_eq!(scrub_suotar_body(&numeric), numeric);
761
762 let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
764 assert_eq!(scrub_suotar_body(&sisu), sisu);
765
766 let adjacent = json!({ "message": "person-012345678 and 012345678 not found" });
769 assert_eq!(
770 scrub_suotar_body(&adjacent),
771 json!({ "message": format!("person-012345678 and {REDACTED} not found") })
772 );
773 }
774
775 #[test]
776 fn free_text_names_are_deliberately_kept() {
777 let scrubbed = scrub_suotar_body(&json!({
779 "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
780 "fullName": "Aada Maria Virtanen",
781 }));
782 assert_eq!(
783 scrubbed,
784 json!({
785 "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
786 "fullName": REDACTED,
787 })
788 );
789 }
790
791 #[test]
792 fn value_scan_leaves_short_and_long_digit_runs_alone() {
793 let body = json!({ "message": "code 404 after 1234567890123 ms" });
794 assert_eq!(scrub_suotar_body(&body), body);
795 }
796
797 #[test]
798 fn scrubbing_is_idempotent() {
799 let body = json!({
800 "studentNumber": "012345678",
801 "message": "Person 012345678 not found",
802 });
803 let once = scrub_suotar_body(&body);
804 assert_eq!(scrub_suotar_body(&once), once);
805 }
806
807 #[test]
808 fn exchange_details_scrub_both_sides_and_omit_missing_ones() {
809 let details = suotar_exchange_details(
810 Some(&json!({ "studentNumber": "012345678" })),
811 Some(&json!({ "code": "sent", "fullName": "Aada Virtanen" })),
812 );
813 assert_eq!(
814 details,
815 json!({
816 "request": { "studentNumber": REDACTED },
817 "response": { "code": "sent", "fullName": REDACTED },
818 })
819 );
820
821 let request_only = suotar_exchange_details(Some(&json!({ "code": "x" })), None);
822 assert_eq!(request_only, json!({ "request": { "code": "x" } }));
823 }
824}