1use std::time::Duration;
16
17use reqwest::{header::HeaderMap, multipart, Client, StatusCode};
18
19use crate::protocol::{Clip, DeviceInfo};
20use crate::rest::{
21 DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
22 DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
23 PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
24};
25use crate::version::ClientInfo;
26
27const MAX_ATTEMPTS: u32 = 3;
28const REQUEST_TIMEOUT_SECS: u64 = 30;
29
30#[derive(Debug, Default, Clone)]
32pub struct ListClipsFilter {
33 pub limit: u32,
34 pub source: Option<String>,
35 pub exclude_source: Option<String>,
36 pub exclude_image: bool,
37 pub exclude_text: bool,
38 pub clip_ids: Vec<String>,
39}
40
41#[derive(Debug, thiserror::Error)]
42pub enum HttpError {
43 #[error("network: {0}")]
44 Network(String),
45 #[error("auth required (401)")]
46 Unauthorized,
47 #[error("relay error ({status}): {message}")]
48 Relay {
49 status: u16,
50 message: String,
51 fix: String,
52 },
53 #[error("decode response: {0}")]
54 Decode(String),
55 #[error("build request: {0}")]
56 Build(String),
57}
58
59#[derive(Debug, Clone)]
60pub struct RestClient {
61 base_url: String,
62 token: String,
63 client: Client,
64 client_info: ClientInfo,
65}
66
67impl RestClient {
68 pub fn new(
74 relay_url: impl Into<String>,
75 token: impl Into<String>,
76 client_info: ClientInfo,
77 ) -> Result<Self, HttpError> {
78 let base = relay_url.into().trim_end_matches('/').to_string();
79 let mut headers = HeaderMap::new();
80 for (name, value) in client_info.http_headers() {
81 headers.insert(name, value);
82 }
83 let client = Client::builder()
84 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
85 .default_headers(headers)
86 .build()
87 .map_err(|e| HttpError::Build(e.to_string()))?;
88 Ok(Self {
89 base_url: base,
90 token: token.into(),
91 client,
92 client_info,
93 })
94 }
95
96 pub fn client_info(&self) -> &ClientInfo {
100 &self.client_info
101 }
102
103 pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
105 let url = format!("{}/clips", self.base_url);
106 let resp = self
107 .send_with_retry(|| {
108 self.client
109 .post(&url)
110 .bearer_auth(&self.token)
111 .json(req)
112 .build()
113 })
114 .await?;
115 decode_push_response(resp).await
116 }
117
118 pub async fn push_clip_binary(
121 &self,
122 data: Vec<u8>,
123 content_type: &str,
124 source: &str,
125 label: Option<&str>,
126 target_device_id: Option<&str>,
127 ) -> Result<PushResponse, HttpError> {
128 let url = format!("{}/clips/binary", self.base_url);
129 let mut last_err: Option<HttpError> = None;
130 for attempt in 0..MAX_ATTEMPTS {
131 if attempt > 0 {
132 tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
133 }
134 let mut form = multipart::Form::new()
137 .part(
138 "file",
139 multipart::Part::bytes(data.clone()).file_name("upload"),
140 )
141 .text("content_type", content_type.to_string())
142 .text("source", source.to_string());
143 if let Some(l) = label.filter(|s| !s.is_empty()) {
144 form = form.text("label", l.to_string());
145 }
146 if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
147 form = form.text("target_device_id", d.to_string());
148 }
149 let resp = self
150 .client
151 .post(&url)
152 .bearer_auth(&self.token)
153 .multipart(form)
154 .send()
155 .await;
156 match resp {
157 Ok(r) => return decode_push_response(r).await,
158 Err(e) => last_err = Some(HttpError::Network(e.to_string())),
159 }
160 }
161 Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
162 }
163
164 pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
166 let url = format!("{}/clips/latest", self.base_url);
167 let resp = self
168 .send_with_retry(|| {
169 self.client
170 .get(&url)
171 .bearer_auth(&self.token)
172 .query(&[("source", source)])
173 .build()
174 })
175 .await?;
176 decode_json_response::<Clip>(resp).await
177 }
178
179 pub async fn get_latest_clip_any(&self) -> Result<Clip, HttpError> {
181 let url = format!("{}/clips/latest", self.base_url);
182 let resp = self
183 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
184 .await?;
185 decode_json_response::<Clip>(resp).await
186 }
187
188 pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
190 let url = format!("{}/clips/{}/media", self.base_url, clip_id);
191 let resp = self
192 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
193 .await?;
194 let status = resp.status();
195 if status == StatusCode::UNAUTHORIZED {
196 return Err(HttpError::Unauthorized);
197 }
198 if !status.is_success() {
199 return Err(HttpError::Relay {
200 status: status.as_u16(),
201 message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
202 fix: String::new(),
203 });
204 }
205 resp.bytes()
206 .await
207 .map(|b| b.to_vec())
208 .map_err(|e| HttpError::Decode(e.to_string()))
209 }
210
211 pub async fn start_device_code(
215 &self,
216 relay_url: &str,
217 hostname: &str,
218 machine_id: &str,
219 user_hint: Option<&str>,
220 ) -> Result<DeviceCodeResponse, HttpError> {
221 let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
222 let req = DeviceCodeRequest {
223 hostname: Some(hostname.to_string()),
224 machine_id: if machine_id.is_empty() {
225 None
226 } else {
227 Some(machine_id.to_string())
228 },
229 user_hint: user_hint.map(|s| s.to_string()),
230 };
231 let resp = self
232 .client
233 .post(&url)
234 .json(&req)
235 .send()
236 .await
237 .map_err(|e| HttpError::Network(e.to_string()))?;
238 decode_json_response::<DeviceCodeResponse>(resp).await
239 }
240
241 pub async fn poll_device_code(
244 &self,
245 relay_url: &str,
246 device_code: &str,
247 ) -> Result<DeviceCodePollResponse, HttpError> {
248 let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
249 let resp = self
250 .client
251 .get(&url)
252 .query(&[("code", device_code)])
253 .send()
254 .await
255 .map_err(|e| HttpError::Network(e.to_string()))?;
256 decode_json_response::<DeviceCodePollResponse>(resp).await
257 }
258
259 pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
262 let url = format!("{}/auth/device-code/complete", self.base_url);
263 let body = DeviceCodeCompleteRequest {
264 user_code: user_code.to_string(),
265 user_id: String::new(),
266 device_id: String::new(),
267 token: String::new(),
268 };
269 let resp = self
270 .client
271 .post(&url)
272 .bearer_auth(&self.token)
273 .json(&body)
274 .send()
275 .await
276 .map_err(|e| HttpError::Network(e.to_string()))?;
277 decode_json_response::<serde_json::Value>(resp)
278 .await
279 .map(|_| ())
280 }
281
282 pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
285 let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
286 let body = DeviceCodeDenyRequest {
287 user_code: user_code.to_string(),
288 };
289 let resp = self
290 .client
291 .post(&url)
292 .bearer_auth(&self.token)
293 .json(&body)
294 .send()
295 .await
296 .map_err(|e| HttpError::Network(e.to_string()))?;
297 decode_json_response::<serde_json::Value>(resp)
298 .await
299 .map(|_| ())
300 }
301
302 pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
306 let url = format!("{}/health", relay_url.trim_end_matches('/'));
307 let resp = self
308 .client
309 .get(&url)
310 .send()
311 .await
312 .map_err(|e| HttpError::Network(e.to_string()))?;
313 if resp.status().is_success() {
314 Ok(())
315 } else {
316 Err(HttpError::Relay {
317 status: resp.status().as_u16(),
318 message: format!("health check failed: HTTP {}", resp.status().as_u16()),
319 fix: String::new(),
320 })
321 }
322 }
323
324 pub async fn post_key_bundle(
331 &self,
332 target_device_id: &str,
333 ephemeral_public_key: &str,
334 encrypted_bundle: &str,
335 ) -> Result<(), HttpError> {
336 let url = format!("{}/auth/key-bundle", self.base_url);
337 let body = KeyBundlePutRequest {
338 device_id: target_device_id.to_string(),
339 ephemeral_public_key: ephemeral_public_key.to_string(),
340 encrypted_bundle: encrypted_bundle.to_string(),
341 };
342 let resp = self
343 .client
344 .post(&url)
345 .bearer_auth(&self.token)
346 .json(&body)
347 .send()
348 .await
349 .map_err(|e| HttpError::Network(e.to_string()))?;
350 let status = resp.status();
351 if status == StatusCode::UNAUTHORIZED {
352 return Err(HttpError::Unauthorized);
353 }
354 if !status.is_success() {
355 return Err(HttpError::Relay {
356 status: status.as_u16(),
357 message: format!("post key bundle failed: HTTP {}", status.as_u16()),
358 fix: String::new(),
359 });
360 }
361 Ok(())
362 }
363
364 pub async fn register_device_public_key(
371 &self,
372 public_key: &str,
373 fingerprint: &str,
374 ) -> Result<(), HttpError> {
375 let url = format!("{}/auth/device/public-key", self.base_url);
376 let body = RegisterDevicePublicKeyRequest {
377 public_key: public_key.to_string(),
378 fingerprint: fingerprint.to_string(),
379 };
380 let resp = self
381 .client
382 .post(&url)
383 .bearer_auth(&self.token)
384 .json(&body)
385 .send()
386 .await
387 .map_err(|e| HttpError::Network(e.to_string()))?;
388 let status = resp.status();
389 if status == StatusCode::UNAUTHORIZED {
390 return Err(HttpError::Unauthorized);
391 }
392 if !status.is_success() {
393 return Err(HttpError::Relay {
394 status: status.as_u16(),
395 message: format!("register public key failed: HTTP {}", status.as_u16()),
396 fix: String::new(),
397 });
398 }
399 Ok(())
400 }
401
402 pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
407 let url = format!("{}/auth/key-bundle/retry", self.base_url);
408 let resp = self
409 .client
410 .post(&url)
411 .bearer_auth(&self.token)
412 .send()
413 .await
414 .map_err(|e| HttpError::Network(e.to_string()))?;
415 let status = resp.status();
416 if status == StatusCode::UNAUTHORIZED {
417 return Err(HttpError::Unauthorized);
418 }
419 if !status.is_success() {
420 return Err(HttpError::Relay {
421 status: status.as_u16(),
422 message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
423 fix: String::new(),
424 });
425 }
426 Ok(())
427 }
428
429 pub async fn set_display_name(&self, name: &str) -> Result<String, HttpError> {
434 if name.trim().is_empty() {
435 return Err(HttpError::Relay {
436 status: 400,
437 message: "display_name must not be empty".into(),
438 fix: "Pass a non-empty name.".into(),
439 });
440 }
441 #[derive(serde::Serialize)]
442 struct Req<'a> {
443 display_name: &'a str,
444 }
445 #[derive(serde::Deserialize)]
446 struct Resp {
447 #[serde(default)]
448 display_name: String,
449 }
450 let url = format!("{}/auth/display-name", self.base_url);
451 let resp = self
452 .client
453 .post(&url)
454 .bearer_auth(&self.token)
455 .json(&Req { display_name: name })
456 .send()
457 .await
458 .map_err(|e| HttpError::Network(e.to_string()))?;
459 let status = resp.status();
460 if status == StatusCode::UNAUTHORIZED {
461 return Err(HttpError::Unauthorized);
462 }
463 if !status.is_success() {
464 return Err(HttpError::Relay {
465 status: status.as_u16(),
466 message: format!("set_display_name failed: HTTP {}", status.as_u16()),
467 fix: String::new(),
468 });
469 }
470 let body: Resp = resp
471 .json()
472 .await
473 .map_err(|e| HttpError::Network(format!("decode set_display_name response: {}", e)))?;
474 Ok(body.display_name)
475 }
476
477 pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
481 let url = format!("{}/auth/device/revoke", self.base_url);
482 let body = DeviceRevokeRequest {
483 device_id: device_id.to_string(),
484 };
485 let resp = self
486 .client
487 .post(&url)
488 .bearer_auth(&self.token)
489 .json(&body)
490 .send()
491 .await
492 .map_err(|e| HttpError::Network(e.to_string()))?;
493 let status = resp.status();
494 if !status.is_success() {
495 return Err(HttpError::Relay {
496 status: status.as_u16(),
497 message: format!("revoke failed: HTTP {}", status.as_u16()),
498 fix: String::new(),
499 });
500 }
501 Ok(())
502 }
503
504 pub async fn set_device_nickname(
509 &self,
510 device_id: &str,
511 nickname: &str,
512 ) -> Result<(), HttpError> {
513 let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
514 #[derive(serde::Serialize)]
515 struct NicknameBody<'a> {
516 nickname: &'a str,
517 }
518 let resp = self
519 .client
520 .put(&url)
521 .bearer_auth(&self.token)
522 .json(&NicknameBody { nickname })
523 .send()
524 .await
525 .map_err(|e| HttpError::Network(e.to_string()))?;
526 let status = resp.status();
527 if !status.is_success() {
528 let body = resp.text().await.unwrap_or_default();
529 return Err(HttpError::Relay {
530 status: status.as_u16(),
531 message: format!("set_device_nickname failed: {}", body),
532 fix: String::new(),
533 });
534 }
535 Ok(())
536 }
537
538 pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
542 let url = format!("{}/devices/self/retention", self.base_url);
543 #[derive(serde::Serialize)]
544 struct Body {
545 remote_retention_days: i32,
546 }
547 let resp = self
548 .client
549 .put(&url)
550 .bearer_auth(&self.token)
551 .json(&Body {
552 remote_retention_days: days,
553 })
554 .send()
555 .await
556 .map_err(|e| HttpError::Network(e.to_string()))?;
557 let status = resp.status();
558 if !status.is_success() {
559 let body = resp.text().await.unwrap_or_default();
560 return Err(HttpError::Relay {
561 status: status.as_u16(),
562 message: format!("set_remote_retention failed: {}", body),
563 fix: String::new(),
564 });
565 }
566 Ok(())
567 }
568
569 pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
576 let url = format!("{}/auth/key-bundle", self.base_url);
577 let resp = self
578 .client
579 .get(&url)
580 .bearer_auth(&self.token)
581 .send()
582 .await
583 .map_err(|e| HttpError::Network(e.to_string()))?;
584 decode_json_response::<KeyBundleResponse>(resp).await
585 }
586
587 pub async fn list_clips_since(
591 &self,
592 since: Option<chrono::DateTime<chrono::Utc>>,
593 limit: u32,
594 ) -> Result<Vec<Clip>, HttpError> {
595 let url = format!("{}/clips", self.base_url);
596 let resp = self
597 .send_with_retry(|| {
598 let mut req = self.client.get(&url).bearer_auth(&self.token);
599 if let Some(ts) = since {
600 req = req.query(&[("since", ts.to_rfc3339())]);
601 }
602 req = req.query(&[("limit", limit.to_string())]);
603 req.build()
604 })
605 .await?;
606 decode_json_response::<Vec<Clip>>(resp).await
607 }
608
609 pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
612 let url = format!("{}/clips", self.base_url);
613 let resp = self
614 .send_with_retry(|| {
615 let mut req = self.client.get(&url).bearer_auth(&self.token);
616 let limit = if filter.limit == 0 {
617 50
618 } else {
619 filter.limit.min(200)
620 };
621 req = req.query(&[("limit", limit.to_string())]);
622 if let Some(s) = &filter.source {
623 req = req.query(&[("source", s.as_str())]);
624 }
625 if let Some(s) = &filter.exclude_source {
626 req = req.query(&[("exclude_source", s.as_str())]);
627 }
628 if filter.exclude_image {
629 req = req.query(&[("exclude_image", "true")]);
630 }
631 if filter.exclude_text {
632 req = req.query(&[("exclude_text", "true")]);
633 }
634 for id in &filter.clip_ids {
635 req = req.query(&[("clip_id", id.as_str())]);
636 }
637 req.build()
638 })
639 .await?;
640 decode_json_response::<Vec<Clip>>(resp).await
641 }
642
643 pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
645 let clips = self
646 .list_clips(ListClipsFilter {
647 limit: 1,
648 clip_ids: vec![clip_id.to_string()],
649 ..Default::default()
650 })
651 .await?;
652 clips.into_iter().next().ok_or_else(|| HttpError::Relay {
653 status: 404,
654 message: format!("Clip {} not found.", clip_id),
655 fix: String::new(),
656 })
657 }
658
659 pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
661 let url = format!("{}/clips/latest", self.base_url);
662 let resp = self
663 .send_with_retry(|| {
664 self.client
665 .get(&url)
666 .bearer_auth(&self.token)
667 .query(&[("exclude_source", exclude_source)])
668 .build()
669 })
670 .await?;
671 decode_json_response::<Clip>(resp).await
672 }
673
674 pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
676 let url = format!("{}/clips/{}", self.base_url, clip_id);
677 let resp = self
678 .send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
679 .await?;
680 let status = resp.status();
681 if status == StatusCode::NOT_FOUND || status.is_success() {
682 return Ok(());
683 }
684 if status == StatusCode::UNAUTHORIZED {
685 return Err(HttpError::Unauthorized);
686 }
687 Err(HttpError::Relay {
688 status: status.as_u16(),
689 message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
690 fix: String::new(),
691 })
692 }
693
694 pub async fn set_clip_pin(
696 &self,
697 clip_id: &str,
698 is_pinned: bool,
699 pin_note: Option<&str>,
700 ) -> Result<(), HttpError> {
701 let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
702 #[derive(serde::Serialize)]
703 struct PinBody<'a> {
704 is_pinned: bool,
705 #[serde(skip_serializing_if = "Option::is_none")]
706 pin_note: Option<&'a str>,
707 }
708 let body = PinBody {
709 is_pinned,
710 pin_note,
711 };
712 let resp = self
713 .send_with_retry(|| {
714 self.client
715 .post(&url)
716 .bearer_auth(&self.token)
717 .json(&body)
718 .build()
719 })
720 .await?;
721 let status = resp.status();
722 if status == StatusCode::NOT_FOUND || status.is_success() {
723 return Ok(());
724 }
725 if status == StatusCode::UNAUTHORIZED {
726 return Err(HttpError::Unauthorized);
727 }
728 Err(HttpError::Relay {
729 status: status.as_u16(),
730 message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
731 fix: String::new(),
732 })
733 }
734
735 pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
737 let url = format!("{}/devices", self.base_url);
738 let resp = self
739 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
740 .await?;
741 decode_json_response::<Vec<DeviceInfo>>(resp).await
742 }
743
744 async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
745 where
746 F: Fn() -> Result<reqwest::Request, reqwest::Error>,
747 {
748 let mut last_err: Option<HttpError> = None;
749 for attempt in 0..MAX_ATTEMPTS {
750 if attempt > 0 {
751 tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
752 }
753 let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
754 match self.client.execute(req).await {
755 Ok(resp) => return Ok(resp),
756 Err(e) => last_err = Some(HttpError::Network(e.to_string())),
757 }
758 }
759 Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
760 }
761}
762
763async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
764 decode_json_response::<PushResponse>(resp).await
765}
766
767async fn decode_json_response<T: serde::de::DeserializeOwned>(
768 resp: reqwest::Response,
769) -> Result<T, HttpError> {
770 let status = resp.status();
771 if status == StatusCode::UNAUTHORIZED {
772 return Err(HttpError::Unauthorized);
773 }
774 if !status.is_success() {
775 let err: ErrorResponse = resp.json().await.unwrap_or_default();
776 let message = if !err.message.is_empty() {
777 err.message
778 } else {
779 err.error
780 };
781 return Err(HttpError::Relay {
782 status: status.as_u16(),
783 message,
784 fix: err.fix,
785 });
786 }
787 resp.json::<T>()
788 .await
789 .map_err(|e| HttpError::Decode(e.to_string()))
790}
791
792#[cfg(test)]
793mod tests {
794 use super::{HttpError, RestClient};
795 use crate::proto::cinch::v1::DeviceCodeStartRequest;
796 use crate::version::ClientInfo;
797 use wiremock::matchers::{body_json, header, method, path};
798 use wiremock::{Mock, MockServer, ResponseTemplate};
799
800 #[test]
801 fn device_code_start_request_includes_user_hint_when_set() {
802 let req = DeviceCodeStartRequest {
803 hostname: Some("dev-box-3".into()),
804 machine_id: Some("m1".into()),
805 user_hint: Some("alice@example.com".into()),
806 };
807 let bytes = serde_json::to_vec(&req).unwrap();
808 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
809 assert_eq!(parsed["user_hint"], "alice@example.com");
810 }
811
812 #[test]
813 fn device_code_start_request_omits_user_hint_when_none() {
814 let req = DeviceCodeStartRequest {
815 hostname: Some("dev-box-3".into()),
816 machine_id: Some("m1".into()),
817 user_hint: None,
818 };
819 let bytes = serde_json::to_vec(&req).unwrap();
820 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
821 assert!(
822 parsed.get("user_hint").is_none(),
823 "user_hint must omit when None"
824 );
825 }
826
827 #[tokio::test]
828 async fn set_display_name_happy_path() {
829 let server = MockServer::start().await;
830 Mock::given(method("POST"))
831 .and(path("/auth/display-name"))
832 .and(header("authorization", "Bearer testtok"))
833 .and(body_json(serde_json::json!({"display_name": "Alice"})))
834 .respond_with(
835 ResponseTemplate::new(200)
836 .set_body_json(serde_json::json!({"ok": true, "display_name": "Alice"})),
837 )
838 .mount(&server)
839 .await;
840
841 let client = RestClient::new(server.uri(), "testtok", ClientInfo::for_test()).unwrap();
842
843 let stored = client.set_display_name("Alice").await.expect("ok");
844 assert_eq!(stored, "Alice");
845 }
846
847 #[tokio::test]
848 async fn set_display_name_rejects_empty() {
849 let client = RestClient::new("http://unused", "t", ClientInfo::for_test()).unwrap();
850 let err = client.set_display_name("").await.expect_err("must reject");
851 assert!(matches!(err, HttpError::Relay { status: 400, .. }));
852 }
853
854 #[tokio::test]
855 async fn set_display_name_propagates_unauthorized() {
856 let server = MockServer::start().await;
857 Mock::given(method("POST"))
858 .and(path("/auth/display-name"))
859 .respond_with(ResponseTemplate::new(401))
860 .mount(&server)
861 .await;
862 let client = RestClient::new(server.uri(), "t", ClientInfo::for_test()).unwrap();
863 let err = client.set_display_name("Bob").await.expect_err("401");
864 assert!(matches!(err, HttpError::Unauthorized));
865 }
866}