1use chrono::DateTime;
17use serde_json::Value;
18
19use crate::error::{AppError, Result};
20use crate::usage::{ModelStudioSnapshot, UsageWindow};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ConsoleSite {
26 Domestic,
27 International,
28}
29
30impl ConsoleSite {
31 pub fn parse(raw: &str) -> Self {
32 if raw.eq_ignore_ascii_case("international") {
33 ConsoleSite::International
34 } else {
35 ConsoleSite::Domestic
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ConsoleRegion {
44 CnBeijing,
45 ApSoutheast1,
46}
47
48impl ConsoleRegion {
49 pub fn parse(raw: &str) -> Self {
50 if raw.eq_ignore_ascii_case("ap-southeast-1") {
51 ConsoleRegion::ApSoutheast1
52 } else {
53 ConsoleRegion::CnBeijing
54 }
55 }
56
57 fn as_str(self) -> &'static str {
58 match self {
59 ConsoleRegion::CnBeijing => "cn-beijing",
60 ConsoleRegion::ApSoutheast1 => "ap-southeast-1",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Gateway {
69 pub host: &'static str,
70 pub action: &'static str,
71}
72
73pub fn gateway_for(region: ConsoleRegion, site: ConsoleSite) -> Gateway {
74 match (region, site) {
75 (ConsoleRegion::CnBeijing, ConsoleSite::Domestic) => Gateway {
76 host: "bailian-cs.console.aliyun.com",
77 action: "BroadScopeAspnGateway",
78 },
79 (ConsoleRegion::CnBeijing, ConsoleSite::International) => Gateway {
80 host: "bailian-cs.console.alibabacloud.com",
81 action: "BroadScopeAspnGateway",
82 },
83 (ConsoleRegion::ApSoutheast1, ConsoleSite::Domestic) => Gateway {
84 host: "modelstudio-cs.console.aliyun.com",
85 action: "IntlBroadScopeAspnGateway",
86 },
87 (ConsoleRegion::ApSoutheast1, ConsoleSite::International) => Gateway {
88 host: "bailian-singapore-cs.alibabacloud.com",
89 action: "IntlBroadScopeAspnGateway",
90 },
91 }
92}
93
94pub const USAGE_API: &str = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
97pub const USAGE_API_ENCODED: &str =
100 "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage";
101pub const PRODUCT: &str = "sfm_bailian";
103
104pub fn usage_path(action: &str) -> String {
106 format!("/cli/api.json?action={action}&product={PRODUCT}&api={USAGE_API_ENCODED}")
107}
108
109pub fn params_body() -> String {
113 serde_json::json!({
114 "Api": USAGE_API,
115 "V": "1.0",
116 "Data": {
117 "cornerstoneParam": {
118 "protocol": "V2",
119 "console": "ONE_CONSOLE",
120 "productCode": "p_efm",
121 "switchUserType": 3,
122 "consoleSite": "BAILIAN_ALIYUN",
123 }
124 },
125 })
126 .to_string()
127}
128
129fn encode_form_value(value: &str) -> String {
133 let mut out = String::with_capacity(value.len());
134 for byte in value.as_bytes() {
135 match byte {
136 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
137 out.push(*byte as char)
138 }
139 _ => out.push_str(&format!("%{byte:02X}")),
140 }
141 }
142 out
143}
144
145pub fn form_body(region: ConsoleRegion) -> String {
147 format!(
148 "region={}¶ms={}",
149 encode_form_value(region.as_str()),
150 encode_form_value(¶ms_body())
151 )
152}
153
154#[derive(Debug, Clone, Copy, PartialEq)]
158pub struct UsageFields {
159 pub per5_hour_percentage: Option<f64>,
160 pub per1_week_percentage: Option<f64>,
161 pub per1_month_percentage: Option<f64>,
162 pub per5_hour_reset_ms: Option<i64>,
163 pub per1_week_reset_ms: Option<i64>,
164 pub per1_month_reset_ms: Option<i64>,
165}
166
167pub fn unwrap_payload(root: &Value) -> &Value {
181 fn present(v: Option<&Value>) -> Option<&Value> {
182 v.filter(|v| !v.is_null())
183 }
184 let datav2 = root.get("data").and_then(|d| d.get("DataV2"));
185 present(
186 datav2
187 .and_then(|d| d.get("data"))
188 .and_then(|d| d.get("data")),
189 )
190 .or_else(|| present(datav2.and_then(|d| d.get("data"))))
191 .or_else(|| present(datav2))
192 .or_else(|| present(root.get("data").and_then(|d| d.get("data"))))
193 .or_else(|| present(root.get("data")))
194 .or_else(|| {
196 let bare = root.get("DataV2");
197 present(bare.and_then(|d| d.get("data")).and_then(|d| d.get("data")))
198 .or_else(|| present(bare.and_then(|d| d.get("data"))))
199 .or_else(|| present(bare))
200 })
201 .unwrap_or(root)
202}
203
204pub fn parse_response(bytes: &[u8]) -> Result<UsageFields> {
208 let root: Value = serde_json::from_slice(bytes)
209 .map_err(|e| AppError::Schema(format!("modelstudio usage response: {e}")))?;
210 if root.get("success").and_then(Value::as_bool) == Some(false) {
211 let code = root
212 .get("errorCode")
213 .and_then(Value::as_str)
214 .unwrap_or("unknown");
215 if code.contains("NotLogined") {
216 return Err(reauth_error());
217 }
218 return Err(AppError::Schema(format!(
219 "modelstudio gateway error: {code}"
220 )));
221 }
222 let payload = unwrap_payload(&root);
223 Ok(UsageFields {
224 per5_hour_percentage: ratio(payload, "per5HourPercentage")?,
225 per1_week_percentage: ratio(payload, "per1WeekPercentage")?,
226 per1_month_percentage: ratio(payload, "per1MonthPercentage")?,
227 per5_hour_reset_ms: epoch_ms(payload, "per5HourResetTime")?,
228 per1_week_reset_ms: epoch_ms(payload, "per1WeekResetTime")?,
229 per1_month_reset_ms: epoch_ms(payload, "per1MonthResetTime")?,
230 })
231}
232
233pub fn reauth_error() -> AppError {
236 AppError::Credentials(
237 "Model Studio: console session expired; run `bl auth login --console` to re-auth".into(),
238 )
239}
240
241fn ratio(payload: &Value, field: &str) -> Result<Option<f64>> {
244 match payload.get(field) {
245 None | Some(Value::Null) => Ok(None),
246 Some(v) => {
247 let raw = v.as_f64().ok_or_else(|| drift(field, "is not a number"))?;
248 if !raw.is_finite() {
249 return Err(drift(field, "is not a finite number"));
250 }
251 if !(0.0..=1.0).contains(&raw) {
252 return Err(drift(field, "is outside [0,1]"));
253 }
254 Ok(Some(raw))
255 }
256 }
257}
258
259fn epoch_ms(payload: &Value, field: &str) -> Result<Option<i64>> {
261 match payload.get(field) {
262 None | Some(Value::Null) => Ok(None),
263 Some(v) => {
264 let raw = v
265 .as_i64()
266 .ok_or_else(|| drift(field, "is not an integer"))?;
267 if raw < 0 {
268 return Err(drift(field, "is negative"));
269 }
270 if chrono::DateTime::from_timestamp_millis(raw).is_none() {
271 return Err(drift(field, "is out of range"));
272 }
273 Ok(Some(raw))
274 }
275 }
276}
277
278fn drift(field: &str, why: &str) -> AppError {
279 AppError::Schema(format!("modelstudio `{field}` {why}"))
280}
281
282pub const FIVE_HOUR_WINDOW: chrono::Duration = chrono::Duration::hours(5);
284pub const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
286pub const MONTHLY_WINDOW: chrono::Duration = chrono::Duration::days(30);
292
293impl UsageFields {
294 pub fn to_snapshot(&self) -> Result<ModelStudioSnapshot> {
295 let window =
296 |ratio: Option<f64>, reset_ms: Option<i64>, duration| -> Result<Option<UsageWindow>> {
297 ratio
298 .map(|r| {
299 Ok(UsageWindow {
300 utilization_pct: ratio_to_percent(r)?,
301 resets_at: match reset_ms {
302 Some(ms) => Some(
303 DateTime::from_timestamp_millis(ms)
304 .ok_or_else(|| drift("reset time", "is out of range"))?,
305 ),
306 None => None,
307 },
308 window_duration: duration,
309 })
310 })
311 .transpose()
312 };
313 Ok(ModelStudioSnapshot {
314 session: window(
315 self.per5_hour_percentage,
316 self.per5_hour_reset_ms,
317 FIVE_HOUR_WINDOW,
318 )?,
319 weekly: window(
320 self.per1_week_percentage,
321 self.per1_week_reset_ms,
322 WEEKLY_WINDOW,
323 )?,
324 monthly: window(
325 self.per1_month_percentage,
326 self.per1_month_reset_ms,
327 MONTHLY_WINDOW,
328 )?,
329 })
330 }
331}
332
333fn ratio_to_percent(ratio: f64) -> Result<i32> {
336 let pct = (ratio * 100.0).round();
337 if !(0.0..=100.0).contains(&pct) {
338 return Err(AppError::Schema(format!(
339 "modelstudio ratio {ratio} did not project onto 0..=100"
340 )));
341 }
342 Ok(pct as i32)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn full_envelope() -> String {
350 serde_json::json!({
351 "success": true,
352 "DataV2": {
353 "success": true,
354 "data": {
355 "data": {
356 "per5HourPercentage": 0.4217,
357 "per5HourResetTime": 1789200000000_i64,
358 "per1WeekPercentage": 0.7356,
359 "per1WeekResetTime": 1789600000000_i64,
360 "per1MonthPercentage": 1.0,
361 "per1MonthResetTime": 1794067200000_i64,
362 }
363 }
364 }
365 })
366 .to_string()
367 }
368
369 #[test]
370 fn gateway_matrix_covers_every_region_site_cell() {
371 assert_eq!(
372 gateway_for(ConsoleRegion::CnBeijing, ConsoleSite::Domestic),
373 Gateway {
374 host: "bailian-cs.console.aliyun.com",
375 action: "BroadScopeAspnGateway"
376 }
377 );
378 assert_eq!(
379 gateway_for(ConsoleRegion::CnBeijing, ConsoleSite::International),
380 Gateway {
381 host: "bailian-cs.console.alibabacloud.com",
382 action: "BroadScopeAspnGateway"
383 }
384 );
385 assert_eq!(
386 gateway_for(ConsoleRegion::ApSoutheast1, ConsoleSite::Domestic),
387 Gateway {
388 host: "modelstudio-cs.console.aliyun.com",
389 action: "IntlBroadScopeAspnGateway"
390 }
391 );
392 assert_eq!(
393 gateway_for(ConsoleRegion::ApSoutheast1, ConsoleSite::International),
394 Gateway {
395 host: "bailian-singapore-cs.alibabacloud.com",
396 action: "IntlBroadScopeAspnGateway"
397 }
398 );
399 }
400
401 #[test]
404 fn unknown_site_and_region_fall_back_to_the_default_row() {
405 assert_eq!(ConsoleSite::parse("weird"), ConsoleSite::Domestic);
406 assert_eq!(ConsoleSite::parse(""), ConsoleSite::Domestic);
407 assert_eq!(
408 ConsoleSite::parse("INTERNATIONAL"),
409 ConsoleSite::International
410 );
411 assert_eq!(
412 ConsoleRegion::parse("eu-central-1"),
413 ConsoleRegion::CnBeijing
414 );
415 assert_eq!(
416 ConsoleRegion::parse("AP-SOUTHEAST-1"),
417 ConsoleRegion::ApSoutheast1
418 );
419 }
420
421 #[test]
424 fn usage_path_encodes_every_slash_in_the_api_param() {
425 let path = usage_path("BroadScopeAspnGateway");
426 assert_eq!(
427 path,
428 "/cli/api.json?action=BroadScopeAspnGateway&product=sfm_bailian\
429 &api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage"
430 );
431 assert_eq!(path.matches("%2F").count(), 5, "{path}");
432 assert!(!USAGE_API_ENCODED.contains('/'));
433 }
434
435 #[test]
436 fn params_body_carries_the_api_version_and_cornerstone_context() {
437 let params: Value = serde_json::from_str(¶ms_body()).unwrap();
438 assert_eq!(params["Api"], USAGE_API);
439 assert_eq!(params["V"], "1.0");
440 assert_eq!(params["Data"]["cornerstoneParam"]["protocol"], "V2");
441 assert_eq!(params["Data"]["cornerstoneParam"]["console"], "ONE_CONSOLE");
442 assert_eq!(params["Data"]["cornerstoneParam"]["productCode"], "p_efm");
443 assert_eq!(params["Data"]["cornerstoneParam"]["switchUserType"], 3);
444 assert_eq!(
445 params["Data"]["cornerstoneParam"]["consoleSite"],
446 "BAILIAN_ALIYUN"
447 );
448 }
449
450 #[test]
451 fn form_body_encodes_the_region_and_params_fields() {
452 let body = form_body(ConsoleRegion::CnBeijing);
453 assert!(body.starts_with("region=cn-beijing¶ms=%7B"), "{body}");
454 assert!(
455 body.contains("%22Api%22%3A%22zeldaHttp.apikeyMgr.%2Ftokenplan"),
456 "{body}"
457 );
458 assert!(body.ends_with("%7D"), "{body}");
459 assert!(
460 form_body(ConsoleRegion::ApSoutheast1).starts_with("region=ap-southeast-1&"),
461 "the region form field carries the wire spelling"
462 );
463 }
464
465 #[test]
467 fn parses_the_full_double_envelope_into_percent_windows() {
468 let fields = parse_response(full_envelope().as_bytes()).unwrap();
469 assert_eq!(fields.per5_hour_percentage, Some(0.4217));
470 assert_eq!(fields.per1_week_percentage, Some(0.7356));
471 assert_eq!(fields.per1_month_percentage, Some(1.0));
472 assert_eq!(fields.per5_hour_reset_ms, Some(1_789_200_000_000));
473 assert_eq!(fields.per1_week_reset_ms, Some(1_789_600_000_000));
474 assert_eq!(fields.per1_month_reset_ms, Some(1_794_067_200_000));
475
476 let snap = fields.to_snapshot().unwrap();
477 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 42);
478 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 74);
479 assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
480 assert_eq!(
481 snap.session.as_ref().unwrap().resets_at,
482 DateTime::from_timestamp_millis(1_789_200_000_000)
483 );
484 assert_eq!(
485 snap.session.as_ref().unwrap().window_duration,
486 FIVE_HOUR_WINDOW
487 );
488 assert_eq!(snap.weekly.as_ref().unwrap().window_duration, WEEKLY_WINDOW);
489 assert_eq!(
490 snap.monthly.as_ref().unwrap().resets_at,
491 DateTime::from_timestamp_millis(1_794_067_200_000)
492 );
493 }
494
495 #[test]
502 fn the_real_gateway_data_envelope_unwraps_to_the_fields() {
503 let fields = parse_response(
504 br#"{"code":"200","data":{"DataV2":{"ret":["SUCCESS::ok"],"data":{
505 "msg":"Success.","code":"SUCCESS","data":{
506 "per5HourPercentage":0.4217,
507 "per5HourResetTime":1789200000000,
508 "per1WeekPercentage":0.7356,
509 "per1WeekResetTime":1789600000000,
510 "per1MonthPercentage":1.0,
511 "per1MonthResetTime":1794067200000
512 },"requestId":"rid-test","success":true}}},
513 "httpStatusCode":"200","successResponse":true}"#,
514 )
515 .unwrap();
516 assert_eq!(fields.per5_hour_percentage, Some(0.4217));
517 assert_eq!(fields.per1_week_percentage, Some(0.7356));
518 assert_eq!(fields.per1_month_percentage, Some(1.0));
519 assert_eq!(fields.per5_hour_reset_ms, Some(1_789_200_000_000));
520 assert_eq!(fields.per1_week_reset_ms, Some(1_789_600_000_000));
521 assert_eq!(fields.per1_month_reset_ms, Some(1_794_067_200_000));
522
523 let snap = fields.to_snapshot().unwrap();
524 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 42);
525 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 74);
526 assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
527 }
528
529 #[test]
533 fn a_monthly_only_envelope_is_a_monthly_window() {
534 let fields = parse_response(
535 br#"{"data":{"per1MonthPercentage":1.0,"per1MonthResetTime":1794067200000}}"#,
536 )
537 .unwrap();
538 assert_eq!(fields.per5_hour_percentage, None);
539 assert_eq!(fields.per1_week_percentage, None);
540 assert_eq!(fields.per1_month_percentage, Some(1.0));
541 let snap = fields.to_snapshot().unwrap();
542 assert!(snap.session.is_none(), "{snap:?}");
543 assert!(snap.weekly.is_none(), "{snap:?}");
544 assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
545 assert_eq!(
546 snap.monthly.as_ref().unwrap().resets_at,
547 DateTime::from_timestamp_millis(1_794_067_200_000)
548 );
549 }
550
551 #[test]
552 fn shallower_envelopes_unwrap_the_same() {
553 let two = serde_json::json!({
554 "DataV2": { "data": {
555 "per5HourPercentage": 0.5,
556 "per1WeekPercentage": 0.25,
557 }}
558 });
559 let fields = parse_response(two.to_string().as_bytes()).unwrap();
560 assert_eq!(fields.per5_hour_percentage, Some(0.5));
561 assert_eq!(fields.per1_week_percentage, Some(0.25));
562
563 let one = serde_json::json!({
564 "DataV2": {
565 "per5HourPercentage": 0.5,
566 "per1WeekPercentage": 0.25,
567 }
568 });
569 let fields = parse_response(one.to_string().as_bytes()).unwrap();
570 assert_eq!(fields.per5_hour_percentage, Some(0.5));
571 assert_eq!(fields.per1_week_percentage, Some(0.25));
572
573 let plain = serde_json::json!({
574 "data": {
575 "per5HourPercentage": 0.5,
576 "per1WeekPercentage": 0.25,
577 }
578 });
579 let fields = parse_response(plain.to_string().as_bytes()).unwrap();
580 assert_eq!(fields.per5_hour_percentage, Some(0.5));
581 assert_eq!(fields.per1_week_percentage, Some(0.25));
582 }
583
584 #[test]
588 fn deeper_envelope_beats_the_shallower_ones() {
589 let v = serde_json::json!({
591 "code": "200",
592 "data": { "DataV2": { "data": { "data": { "per5HourPercentage": 0.11 },
593 "per5HourPercentage": 0.22 },
594 "per5HourPercentage": 0.33 },
595 "per5HourPercentage": 0.44 }
596 });
597 assert_eq!(
598 unwrap_payload(&v)
599 .get("per5HourPercentage")
600 .and_then(Value::as_f64),
601 Some(0.11)
602 );
603
604 let v = serde_json::json!({
607 "data": { "DataV2": { "data": null, "per5HourPercentage": 0.33 },
608 "per5HourPercentage": 0.44 }
609 });
610 assert_eq!(
611 unwrap_payload(&v)
612 .get("per5HourPercentage")
613 .and_then(Value::as_f64),
614 Some(0.33)
615 );
616
617 let v = serde_json::json!({ "data": { "per5HourPercentage": 0.66 } });
621 assert_eq!(
622 unwrap_payload(&v)
623 .get("per5HourPercentage")
624 .and_then(Value::as_f64),
625 Some(0.66)
626 );
627 let v = serde_json::json!({ "data": { "data": { "per5HourPercentage": 0.77 } } });
628 assert_eq!(
629 unwrap_payload(&v)
630 .get("per5HourPercentage")
631 .and_then(Value::as_f64),
632 Some(0.77)
633 );
634 let v = serde_json::json!({ "per5HourPercentage": 0.55 });
636 assert_eq!(
637 unwrap_payload(&v)
638 .get("per5HourPercentage")
639 .and_then(Value::as_f64),
640 Some(0.55)
641 );
642 assert_eq!(unwrap_payload(&v), &v);
643 }
644
645 #[test]
647 fn absent_percentages_leave_the_window_out() {
648 let fields = parse_response(
649 br#"{"data":{"per1WeekPercentage":0.5,"per1WeekResetTime":1789600000000}}"#,
650 )
651 .unwrap();
652 assert_eq!(fields.per5_hour_percentage, None);
653 let snap = fields.to_snapshot().unwrap();
654 assert!(snap.session.is_none(), "{snap:?}");
655 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 50);
656 }
657
658 #[test]
659 fn ratio_math_rounds_after_the_times_hundred() {
660 for (ratio, pct) in [(0.4217, 42), (0.005, 1), (0.0, 0), (1.0, 100), (0.995, 100)] {
661 let fields = UsageFields {
662 per5_hour_percentage: Some(ratio),
663 per1_week_percentage: None,
664 per1_month_percentage: None,
665 per5_hour_reset_ms: None,
666 per1_week_reset_ms: None,
667 per1_month_reset_ms: None,
668 };
669 assert_eq!(
670 fields
671 .to_snapshot()
672 .unwrap()
673 .session
674 .unwrap()
675 .utilization_pct,
676 pct,
677 "{ratio}"
678 );
679 }
680 }
681
682 #[test]
685 fn invalid_ratios_are_schema_drift_never_zero() {
686 for raw in ["NaN", "-0.1", "1.4", "2", "null_plus", "\"0.5\"", "true"] {
687 let body = format!(r#"{{"data":{{"per5HourPercentage":{raw}}}}}"#);
688 let err = parse_response(body.as_bytes()).unwrap_err();
689 assert!(matches!(err, AppError::Schema(_)), "{raw}: {err:?}");
690 }
691 }
692
693 #[test]
694 fn invalid_reset_times_are_schema_drift() {
695 for raw in ["-1", "\"soon\"", "1e30", "true"] {
696 let body = format!(r#"{{"data":{{"per5HourResetTime":{raw}}}}}"#);
697 assert!(
698 parse_response(body.as_bytes()).is_err(),
699 "{raw} must not parse"
700 );
701 }
702 }
703
704 #[test]
705 fn null_optional_fields_count_as_absent() {
706 let fields =
707 parse_response(br#"{"data":{"per5HourPercentage":null,"per5HourResetTime":null}}"#)
708 .unwrap();
709 assert_eq!(fields.per5_hour_percentage, None);
710 assert_eq!(fields.per5_hour_reset_ms, None);
711 }
712
713 #[test]
716 fn not_logined_is_the_reauth_error() {
717 for code in ["NotLogined", "NotLogined_1001", "FLOW.NotLogined"] {
718 let body = format!(r#"{{"success":false,"errorCode":"{code}"}}"#);
719 let err = parse_response(body.as_bytes()).unwrap_err();
720 assert!(matches!(err, AppError::Credentials(_)), "{code}: {err:?}");
721 assert!(
722 err.to_string().contains("bl auth login --console"),
723 "{code}: {err}"
724 );
725 }
726 }
727
728 #[test]
730 fn other_gateway_failures_are_schema_errors() {
731 let err = parse_response(br#"{"success":false,"errorCode":"NoPermission"}"#).unwrap_err();
732 assert!(matches!(err, AppError::Schema(_)), "{err:?}");
733 assert!(err.to_string().contains("NoPermission"), "{err}");
734 assert!(parse_response(br#"{"data":{}}"#).is_ok());
736 assert!(parse_response(br#"{"success":true,"data":{}}"#).is_ok());
737 }
738
739 #[test]
740 fn non_json_is_schema_drift() {
741 let err = parse_response(b"<html>login page</html>").unwrap_err();
742 assert!(matches!(err, AppError::Schema(_)), "{err:?}");
743 }
744}