1use std::sync::Arc;
6
7use parking_lot::Mutex;
8use reqwest::StatusCode;
9use serde_json::Value;
10
11use crate::config::Config;
12
13#[derive(Clone)]
18pub struct GraphQLClient {
19 url: String,
20 http: reqwest::blocking::Client,
21 session: Option<Arc<Session>>,
22}
23
24struct Session {
27 tokens: Mutex<Tokens>,
28 on_rotate: Box<dyn Fn(&str) + Send + Sync>,
31}
32
33struct Tokens {
34 access: Option<String>,
35 refresh: String,
36 refused: Option<String>,
39}
40
41impl GraphQLClient {
42 pub fn new(server_url: &str) -> Self {
43 let url = format!("{}/graphql", server_url.trim_end_matches('/'));
44 Self {
45 url,
46 http: reqwest::blocking::Client::builder()
47 .timeout(std::time::Duration::from_secs(30))
48 .build()
49 .expect("failed to build HTTP client"),
50 session: None,
51 }
52 }
53
54 pub fn from_config(server_url: &str) -> Self {
57 let client = Self::new(server_url);
58 let cfg = Config::load().unwrap_or_default();
59 if cfg.auth.refresh_token.is_empty()
60 || cfg.auth.server.trim_end_matches('/') != client.server_url()
61 {
62 return client;
63 }
64 client.with_session(cfg.auth.refresh_token, |token| {
65 if let Err(e) = Config::persist(|cfg| cfg.auth.refresh_token = token.to_owned()) {
66 log::warn!("could not store the rotated refresh token: {e}");
67 }
68 })
69 }
70
71 pub fn with_session(
74 mut self,
75 refresh_token: impl Into<String>,
76 on_rotate: impl Fn(&str) + Send + Sync + 'static,
77 ) -> Self {
78 self.session = Some(Arc::new(Session {
79 tokens: Mutex::new(Tokens {
80 access: None,
81 refresh: refresh_token.into(),
82 refused: None,
83 }),
84 on_rotate: Box::new(on_rotate),
85 }));
86 self
87 }
88
89 pub fn has_session(&self) -> bool {
91 self.session.is_some()
92 }
93
94 pub fn execute(&self, query: &str, variables: Option<Value>) -> Result<Value, GraphQLError> {
99 let mut body = serde_json::json!({ "query": query });
100 if let Some(vars) = variables {
101 body["variables"] = vars;
102 }
103
104 let access = self
105 .session
106 .as_ref()
107 .and_then(|s| s.tokens.lock().access.clone());
108 let mut resp = self.post(&body, access.as_deref())?;
109 if resp.status() == StatusCode::UNAUTHORIZED
110 && let Some(session) = &self.session
111 {
112 let fresh = self.refresh(session, access.as_deref())?;
113 resp = self.post(&body, Some(&fresh))?;
114 }
115
116 let status = resp.status();
117 if status == StatusCode::UNAUTHORIZED {
118 return Err(GraphQLError::Unauthorized(reason(resp)));
119 }
120 if !status.is_success() {
121 return Err(GraphQLError::Http(format!("{status}: {}", reason(resp))));
122 }
123
124 let resp: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
125
126 if let Some(errors) = resp.get("errors")
127 && let Some(arr) = errors.as_array()
128 && !arr.is_empty()
129 {
130 let msg = arr[0]
131 .get("message")
132 .and_then(|m| m.as_str())
133 .unwrap_or("unknown error");
134 return Err(GraphQLError::Query(msg.to_string()));
135 }
136
137 Ok(resp.get("data").cloned().unwrap_or(Value::Null))
138 }
139
140 fn post(
141 &self,
142 body: &Value,
143 access: Option<&str>,
144 ) -> Result<reqwest::blocking::Response, GraphQLError> {
145 let mut req = self.http.post(&self.url).json(body);
146 if let Some(token) = access {
147 req = req.bearer_auth(token);
148 }
149 req.send().map_err(|e| GraphQLError::Http(e.to_string()))
150 }
151
152 fn refresh(&self, session: &Session, stale: Option<&str>) -> Result<String, GraphQLError> {
159 let mut tokens = session.tokens.lock();
160 if let Some(access) = &tokens.access
161 && Some(access.as_str()) != stale
162 {
163 return Ok(access.clone());
164 }
165 if let Some(reason) = &tokens.refused {
166 return Err(GraphQLError::Unauthorized(reason.clone()));
167 }
168
169 let resp = self
170 .http
171 .post(format!("{}/auth/refresh", self.server_url()))
172 .json(&serde_json::json!({ "refresh_token": tokens.refresh }))
173 .send()
174 .map_err(|e| GraphQLError::Http(e.to_string()))?;
175 if resp.status() == StatusCode::UNAUTHORIZED {
176 let refused = format!("the stored sign-in was refused: {}", reason(resp));
177 tokens.access = None;
178 tokens.refused = Some(refused.clone());
179 return Err(GraphQLError::Unauthorized(refused));
180 }
181 if !resp.status().is_success() {
182 let status = resp.status();
183 return Err(GraphQLError::Http(format!(
184 "/auth/refresh {status}: {}",
185 reason(resp)
186 )));
187 }
188
189 let body: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
190 let (Some(access), Some(refresh)) = (
191 body["access_token"].as_str(),
192 body["refresh_token"].as_str(),
193 ) else {
194 return Err(GraphQLError::Http(
195 "malformed /auth/refresh response".into(),
196 ));
197 };
198 tokens.access = Some(access.to_owned());
199 tokens.refresh = refresh.to_owned();
200 (session.on_rotate)(refresh);
201 Ok(access.to_owned())
202 }
203
204 pub fn now_playing(&self) -> Result<NowPlaying, GraphQLError> {
209 let data = self.execute(
210 "{ nowPlaying { state positionMs durationMs queueItemId \
211 track { trackId title artist album codec sampleRate bitDepth bitrateKbps channels durationMs } } }",
212 None,
213 )?;
214 let np = &data["nowPlaying"];
215 Ok(NowPlaying {
216 state: np["state"].as_str().unwrap_or("STOPPED").to_string(),
217 position_ms: np["positionMs"].as_u64().unwrap_or(0),
218 duration_ms: np["durationMs"].as_u64(),
219 queue_item_id: np["queueItemId"].as_str().map(String::from),
220 track: np.get("track").and_then(|t| {
221 if t.is_null() {
222 return None;
223 }
224 Some(NowPlayingTrack {
225 track_id: t["trackId"].as_str().map(String::from),
226 title: t["title"].as_str().unwrap_or("").to_string(),
227 artist: t["artist"].as_str().unwrap_or("").to_string(),
228 album: t["album"].as_str().unwrap_or("").to_string(),
229 codec: t["codec"].as_str().unwrap_or("").to_string(),
230 sample_rate: t["sampleRate"].as_u64().unwrap_or(0) as u32,
231 bit_depth: t["bitDepth"].as_u64().map(|v| v as u16),
232 bitrate_kbps: t["bitrateKbps"].as_u64().map(|v| v as u32),
233 channels: t["channels"].as_u64().unwrap_or(0) as u16,
234 duration_ms: t["durationMs"].as_u64().unwrap_or(0),
235 })
236 }),
237 })
238 }
239
240 pub fn queue(&self) -> Result<Vec<QueueEntry>, GraphQLError> {
241 let data = self.execute(
242 "{ queue { queueItemId trackId title artist album codec trackNumber disc durationMs isCurrent } }",
243 None,
244 )?;
245 let entries = data["queue"]
246 .as_array()
247 .map(|arr| {
248 arr.iter()
249 .map(|e| QueueEntry {
250 queue_item_id: e["queueItemId"].as_str().unwrap_or("").to_string(),
251 track_id: e["trackId"].as_str().map(String::from),
252 title: e["title"].as_str().unwrap_or("").to_string(),
253 artist: e["artist"].as_str().unwrap_or("").to_string(),
254 album: e["album"].as_str().unwrap_or("").to_string(),
255 codec: e["codec"].as_str().map(String::from),
256 track_number: e["trackNumber"].as_i64(),
257 disc: e["disc"].as_i64(),
258 duration_ms: e["durationMs"].as_u64(),
259 is_current: e["isCurrent"].as_bool().unwrap_or(false),
260 })
261 .collect()
262 })
263 .unwrap_or_default();
264 Ok(entries)
265 }
266
267 pub fn search(&self, query: &str, limit: u32) -> Result<Vec<TrackResult>, GraphQLError> {
268 let data = self.execute(
269 "query($search: String!, $first: Int) { tracks(search: $search, first: $first) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
270 Some(serde_json::json!({ "search": query, "first": limit })),
271 )?;
272 parse_track_edges(&data["tracks"])
273 }
274
275 pub fn artists(&self) -> Result<Vec<ArtistResult>, GraphQLError> {
276 let data = self.execute("{ artists { edges { node { id name } } } }", None)?;
277 let edges = data["artists"]["edges"].as_array();
278 Ok(edges
279 .map(|arr| {
280 arr.iter()
281 .map(|e| {
282 let n = &e["node"];
283 ArtistResult {
284 id: n["id"].as_str().unwrap_or("").to_string(),
285 name: n["name"].as_str().unwrap_or("").to_string(),
286 }
287 })
288 .collect()
289 })
290 .unwrap_or_default())
291 }
292
293 pub fn albums_for_artist(&self, artist_id: &str) -> Result<Vec<AlbumResult>, GraphQLError> {
294 let data = self.execute(
295 "query($artistId: ID!) { albums(artistId: $artistId) { edges { node { id title artistName date codec } } } }",
296 Some(serde_json::json!({ "artistId": artist_id })),
297 )?;
298 parse_album_edges(&data["albums"])
299 }
300
301 pub fn tracks_for_album(&self, album_id: &str) -> Result<Vec<TrackResult>, GraphQLError> {
302 let data = self.execute(
303 "query($albumId: ID!) { tracks(albumId: $albumId) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
304 Some(serde_json::json!({ "albumId": album_id })),
305 )?;
306 parse_track_edges(&data["tracks"])
307 }
308
309 pub fn fuzzy_search(
310 &self,
311 query: &str,
312 kind: &str,
313 limit: u32,
314 ) -> Result<Vec<FuzzyMatch>, GraphQLError> {
315 let data = self.execute(
316 "query($query: String!, $kind: FuzzySearchKind!, $limit: Int) { fuzzySearch(query: $query, kind: $kind, limit: $limit) { id name rank kind } }",
317 Some(serde_json::json!({ "query": query, "kind": kind, "limit": limit })),
318 )?;
319 Ok(data["fuzzySearch"]
320 .as_array()
321 .map(|arr| {
322 arr.iter()
323 .map(|e| FuzzyMatch {
324 id: e["id"].as_str().unwrap_or("").to_string(),
325 name: e["name"].as_str().unwrap_or("").to_string(),
326 rank: e["rank"].as_i64().unwrap_or(0) as i32,
327 })
328 .collect()
329 })
330 .unwrap_or_default())
331 }
332
333 pub fn pause(&self) -> Result<(), GraphQLError> {
336 self.execute("mutation { pause { ok } }", None)?;
337 Ok(())
338 }
339
340 pub fn resume(&self) -> Result<(), GraphQLError> {
341 self.execute("mutation { resume { ok } }", None)?;
342 Ok(())
343 }
344
345 pub fn stop(&self) -> Result<(), GraphQLError> {
346 self.execute("mutation { stop { ok } }", None)?;
347 Ok(())
348 }
349
350 pub fn next(&self) -> Result<(), GraphQLError> {
351 self.execute("mutation { next { ok } }", None)?;
352 Ok(())
353 }
354
355 pub fn previous(&self) -> Result<(), GraphQLError> {
356 self.execute("mutation { previous { ok } }", None)?;
357 Ok(())
358 }
359
360 pub fn seek(&self, position_ms: u64) -> Result<(), GraphQLError> {
361 self.execute(
362 "mutation($positionMs: Int!) { seek(positionMs: $positionMs) { ok } }",
363 Some(serde_json::json!({ "positionMs": position_ms })),
364 )?;
365 Ok(())
366 }
367
368 pub fn play(&self, queue_item_id: &str) -> Result<(), GraphQLError> {
369 self.execute(
370 "mutation($queueItemId: String!) { play(queueItemId: $queueItemId) { ok } }",
371 Some(serde_json::json!({ "queueItemId": queue_item_id })),
372 )?;
373 Ok(())
374 }
375
376 pub fn add_to_queue(&self, track_ids: &[String]) -> Result<Vec<String>, GraphQLError> {
377 let data = self.execute(
378 "mutation($trackIds: [ID!]!) { addToQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
379 Some(serde_json::json!({ "trackIds": track_ids })),
380 )?;
381 Ok(data["addToQueue"]["queueItemIds"]
382 .as_array()
383 .map(|arr| {
384 arr.iter()
385 .filter_map(|v| v.as_str().map(String::from))
386 .collect()
387 })
388 .unwrap_or_default())
389 }
390
391 pub fn replace_queue(&self, track_ids: &[String]) -> Result<Vec<String>, GraphQLError> {
392 let data = self.execute(
393 "mutation($trackIds: [ID!]!) { replaceQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
394 Some(serde_json::json!({ "trackIds": track_ids })),
395 )?;
396 Ok(data["replaceQueue"]["queueItemIds"]
397 .as_array()
398 .map(|arr| {
399 arr.iter()
400 .filter_map(|v| v.as_str().map(String::from))
401 .collect()
402 })
403 .unwrap_or_default())
404 }
405
406 pub fn clear_queue(&self) -> Result<(), GraphQLError> {
407 self.execute("mutation { clearQueue { ok } }", None)?;
408 Ok(())
409 }
410
411 pub fn favourite(&self, track_id: &str) -> Result<(), GraphQLError> {
412 self.execute(
413 "mutation($trackId: ID!) { favourite(trackId: $trackId) { id } }",
414 Some(serde_json::json!({ "trackId": track_id })),
415 )?;
416 Ok(())
417 }
418
419 pub fn unfavourite(&self, track_id: &str) -> Result<(), GraphQLError> {
420 self.execute(
421 "mutation($trackId: ID!) { unfavourite(trackId: $trackId) { id } }",
422 Some(serde_json::json!({ "trackId": track_id })),
423 )?;
424 Ok(())
425 }
426
427 pub fn save_queue_as_playlist(&self, name: &str) -> Result<(), GraphQLError> {
428 self.execute(
429 "mutation($name: String!) { saveQueueAsPlaylist(name: $name) { id } }",
430 Some(serde_json::json!({ "name": name })),
431 )?;
432 Ok(())
433 }
434
435 pub fn play_playlist(&self, id: &str, shuffled: bool) -> Result<(), GraphQLError> {
436 self.execute(
437 "mutation($id: ID!, $shuffled: Boolean!) { playPlaylist(id: $id, shuffled: $shuffled) { ok } }",
438 Some(serde_json::json!({ "id": id, "shuffled": shuffled })),
439 )?;
440 Ok(())
441 }
442
443 pub fn enable_radio(&self) -> Result<(), GraphQLError> {
444 self.execute("mutation { enableRadio { ok } }", None)?;
445 Ok(())
446 }
447
448 pub fn disable_radio(&self) -> Result<(), GraphQLError> {
449 self.execute("mutation { disableRadio { ok } }", None)?;
450 Ok(())
451 }
452
453 pub fn library_stats(&self) -> Result<Value, GraphQLError> {
454 self.execute(
455 "{ libraryStats { totalTracks totalArtists totalAlbums localTracks remoteTracks cachedTracks } }",
456 None,
457 )
458 }
459
460 pub fn server_url(&self) -> &str {
462 self.url.trim_end_matches("/graphql")
463 }
464}
465
466#[derive(Debug, thiserror::Error)]
471pub enum GraphQLError {
472 #[error("http error: {0}")]
473 Http(String),
474 #[error("query error: {0}")]
475 Query(String),
476 #[error("unauthorised: {0}")]
477 Unauthorized(String),
478}
479
480fn reason(resp: reqwest::blocking::Response) -> String {
483 let status = resp.status();
484 let text = resp.text().unwrap_or_default();
485 let message = serde_json::from_str::<Value>(&text)
486 .ok()
487 .and_then(|v| v["message"].as_str().map(str::to_owned))
488 .unwrap_or(text);
489 if message.trim().is_empty() {
490 status.to_string()
491 } else {
492 message.trim().to_owned()
493 }
494}
495
496#[derive(Debug, Clone)]
497pub struct NowPlaying {
498 pub state: String,
499 pub position_ms: u64,
500 pub duration_ms: Option<u64>,
501 pub queue_item_id: Option<String>,
502 pub track: Option<NowPlayingTrack>,
503}
504
505#[derive(Debug, Clone)]
506pub struct NowPlayingTrack {
507 pub track_id: Option<String>,
510 pub title: String,
511 pub artist: String,
512 pub album: String,
513 pub codec: String,
514 pub sample_rate: u32,
515 pub bit_depth: Option<u16>,
516 pub bitrate_kbps: Option<u32>,
517 pub channels: u16,
518 pub duration_ms: u64,
519}
520
521#[derive(Debug, Clone)]
522pub struct QueueEntry {
523 pub queue_item_id: String,
524 pub track_id: Option<String>,
525 pub title: String,
526 pub artist: String,
527 pub album: String,
528 pub codec: Option<String>,
529 pub track_number: Option<i64>,
530 pub disc: Option<i64>,
531 pub duration_ms: Option<u64>,
532 pub is_current: bool,
533}
534
535#[derive(Debug, Clone)]
536pub struct TrackResult {
537 pub id: String,
538 pub title: String,
539 pub artist: String,
540 pub album: String,
541 pub album_id: Option<String>,
542 pub artist_id: Option<String>,
543 pub disc: Option<i32>,
544 pub track_number: Option<i32>,
545 pub duration_ms: Option<i64>,
546 pub codec: Option<String>,
547 pub genre: Option<String>,
548 pub source: String,
549}
550
551#[derive(Debug, Clone)]
552pub struct ArtistResult {
553 pub id: String,
554 pub name: String,
555}
556
557#[derive(Debug, Clone)]
558pub struct AlbumResult {
559 pub id: String,
560 pub title: String,
561 pub artist_name: String,
562 pub date: Option<String>,
563 pub codec: Option<String>,
564}
565
566#[derive(Debug, Clone)]
567pub struct FuzzyMatch {
568 pub id: String,
569 pub name: String,
570 pub rank: i32,
571}
572
573fn parse_track_edges(connection: &Value) -> Result<Vec<TrackResult>, GraphQLError> {
578 Ok(connection["edges"]
579 .as_array()
580 .map(|arr| {
581 arr.iter()
582 .map(|e| {
583 let n = &e["node"];
584 TrackResult {
585 id: n["id"].as_str().unwrap_or("").to_string(),
586 title: n["title"].as_str().unwrap_or("").to_string(),
587 artist: n["artist"].as_str().unwrap_or("").to_string(),
588 album: n["album"].as_str().unwrap_or("").to_string(),
589 album_id: n["albumId"].as_str().map(String::from),
590 artist_id: n["artistId"].as_str().map(String::from),
591 disc: n["disc"].as_i64().map(|v| v as i32),
592 track_number: n["trackNumber"].as_i64().map(|v| v as i32),
593 duration_ms: n["durationMs"].as_i64(),
594 codec: n["codec"].as_str().map(String::from),
595 genre: n["genre"].as_str().map(String::from),
596 source: n["source"].as_str().unwrap_or("local").to_string(),
597 }
598 })
599 .collect()
600 })
601 .unwrap_or_default())
602}
603
604fn parse_album_edges(connection: &Value) -> Result<Vec<AlbumResult>, GraphQLError> {
605 Ok(connection["edges"]
606 .as_array()
607 .map(|arr| {
608 arr.iter()
609 .map(|e| {
610 let n = &e["node"];
611 AlbumResult {
612 id: n["id"].as_str().unwrap_or("").to_string(),
613 title: n["title"].as_str().unwrap_or("").to_string(),
614 artist_name: n["artistName"].as_str().unwrap_or("").to_string(),
615 date: n["date"].as_str().map(String::from),
616 codec: n["codec"].as_str().map(String::from),
617 }
618 })
619 .collect()
620 })
621 .unwrap_or_default())
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn client_constructs_url() {
630 let c = GraphQLClient::new("http://localhost:4000");
631 assert_eq!(c.url, "http://localhost:4000/graphql");
632 }
633
634 #[test]
635 fn client_trailing_slash() {
636 let c = GraphQLClient::new("http://localhost:4000/");
637 assert_eq!(c.url, "http://localhost:4000/graphql");
638 }
639
640 struct Seen {
642 path: String,
643 bearer: Option<String>,
644 body: String,
645 }
646
647 fn serve(
650 respond: impl Fn(&Seen) -> (u16, &'static str) + Send + 'static,
651 ) -> (String, Arc<Mutex<Vec<String>>>) {
652 use std::io::{BufRead, BufReader, Read, Write};
653
654 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
655 let base = format!("http://{}", listener.local_addr().unwrap());
656 let log = Arc::new(Mutex::new(Vec::new()));
657 let seen_log = log.clone();
658 std::thread::spawn(move || {
659 for stream in listener.incoming() {
660 let Ok(mut stream) = stream else { return };
661 let mut reader = BufReader::new(stream.try_clone().unwrap());
662 let mut line = String::new();
663 reader.read_line(&mut line).unwrap();
664 let path = line.split_whitespace().nth(1).unwrap_or("").to_owned();
665 let (mut bearer, mut length) = (None, 0);
666 loop {
667 line.clear();
668 reader.read_line(&mut line).unwrap();
669 let header = line.trim_end();
670 if header.is_empty() {
671 break;
672 }
673 let (name, value) = header.split_once(": ").unwrap_or((header, ""));
674 match name.to_ascii_lowercase().as_str() {
675 "authorization" => {
676 bearer = value.strip_prefix("Bearer ").map(str::to_owned)
677 }
678 "content-length" => length = value.parse().unwrap_or(0),
679 _ => {}
680 }
681 }
682 let mut body = vec![0; length];
683 reader.read_exact(&mut body).unwrap();
684 let seen = Seen {
685 path,
686 bearer,
687 body: String::from_utf8_lossy(&body).into_owned(),
688 };
689 seen_log.lock().push(seen.path.clone());
690 let (code, reply) = respond(&seen);
691 write!(
692 stream,
693 "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\n\
694 Content-Length: {}\r\nConnection: close\r\n\r\n{reply}",
695 reply.len()
696 )
697 .unwrap();
698 }
699 });
700 (base, log)
701 }
702
703 fn signed_in_server(seen: &Seen) -> (u16, &'static str) {
705 match seen.path.as_str() {
706 "/graphql" if seen.bearer.as_deref() == Some("access-2") => {
707 (200, r#"{"data":{"ok":true}}"#)
708 }
709 "/auth/refresh" if seen.body.contains("refresh-1") => (
710 200,
711 r#"{"access_token":"access-2","refresh_token":"refresh-2"}"#,
712 ),
713 "/auth/refresh" => (401, r#"{"message":"invalid or expired refresh token"}"#),
714 _ => (401, "missing or invalid Authorization header"),
715 }
716 }
717
718 #[test]
719 fn refused_request_refreshes_once_and_retries() {
720 let (base, log) = serve(signed_in_server);
721 let rotated = Arc::new(Mutex::new(Vec::new()));
722 let stored = rotated.clone();
723 let client = GraphQLClient::new(&base)
724 .with_session("refresh-1", move |t| stored.lock().push(t.to_owned()));
725
726 let data = client.execute("{ ok }", None).unwrap();
727 assert_eq!(data["ok"], true);
728 assert_eq!(*rotated.lock(), ["refresh-2"]);
729
730 client.clone().execute("{ ok }", None).unwrap();
732 assert_eq!(
733 *log.lock(),
734 ["/graphql", "/auth/refresh", "/graphql", "/graphql"]
735 );
736 }
737
738 #[test]
739 fn refused_refresh_is_unauthorised() {
740 let (base, log) = serve(signed_in_server);
741 let client = GraphQLClient::new(&base).with_session("revoked", |_| {});
742
743 for _ in 0..2 {
744 match client.execute("{ ok }", None) {
745 Err(GraphQLError::Unauthorized(msg)) => {
746 assert!(msg.contains("invalid or expired refresh token"), "{msg}")
747 }
748 other => panic!("expected Unauthorized, got {other:?}"),
749 }
750 }
751 assert_eq!(*log.lock(), ["/graphql", "/auth/refresh", "/graphql"]);
753 }
754
755 #[test]
756 fn unauthorised_without_a_session_says_why() {
757 let (base, log) = serve(signed_in_server);
758
759 match GraphQLClient::new(&base).execute("{ ok }", None) {
760 Err(GraphQLError::Unauthorized(msg)) => {
761 assert_eq!(msg, "missing or invalid Authorization header")
762 }
763 other => panic!("expected Unauthorized, got {other:?}"),
764 }
765 assert_eq!(*log.lock(), ["/graphql"]);
766 }
767}