Skip to main content

devboy_fireflies/
client.rs

1//! Fireflies.ai GraphQL client implementing `MeetingNotesProvider`.
2
3use async_trait::async_trait;
4use devboy_core::{
5    Error, MeetingFilter, MeetingNote, MeetingNotesProvider, MeetingSpeaker, MeetingTranscript,
6    ProviderResult, Result, TranscriptSentence,
7};
8use secrecy::{ExposeSecret, SecretString};
9use serde_json::{Value, json};
10use tracing::debug;
11
12use crate::types::*;
13
14const FIREFLIES_API_URL: &str = "https://api.fireflies.ai/graphql";
15
16/// GraphQL query for listing transcripts.
17const GET_TRANSCRIPTS_QUERY: &str = r#"
18query GetTranscripts(
19  $keyword: String
20  $fromDate: DateTime
21  $toDate: DateTime
22  $limit: Int
23  $skip: Int
24  $host_email: String
25  $participants: [String!]
26) {
27  transcripts(
28    keyword: $keyword
29    fromDate: $fromDate
30    toDate: $toDate
31    limit: $limit
32    skip: $skip
33    host_email: $host_email
34    participants: $participants
35  ) {
36    id
37    title
38    date
39    duration
40    host_email
41    organizer_email
42    meeting_attendees { displayName email name }
43    speakers { id name }
44    transcript_url
45    audio_url
46    video_url
47    meeting_link
48    summary {
49      keywords
50      action_items
51      topics_discussed
52      meeting_type
53      overview
54      short_summary
55    }
56  }
57}
58"#;
59
60/// GraphQL query for a single transcript with sentences.
61const GET_TRANSCRIPT_QUERY: &str = r#"
62query GetTranscript($transcriptId: String!) {
63  transcript(id: $transcriptId) {
64    id
65    title
66    date
67    duration
68    speakers { id name }
69    sentences {
70      speaker_id
71      text
72      start_time
73      end_time
74    }
75  }
76}
77"#;
78
79/// Fireflies.ai API client.
80pub struct FirefliesClient {
81    api_key: SecretString,
82    api_url: String,
83    http: reqwest::Client,
84}
85
86impl FirefliesClient {
87    pub fn new(api_key: SecretString) -> Self {
88        Self {
89            api_key,
90            api_url: FIREFLIES_API_URL.to_string(),
91            http: reqwest::Client::new(),
92        }
93    }
94
95    /// Override the GraphQL endpoint. Only meaningful in tests — the
96    /// default points at the hosted Fireflies API.
97    pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
98        self.api_url = url.into();
99        self
100    }
101
102    /// Execute a GraphQL query against the Fireflies API.
103    async fn graphql<T: serde::de::DeserializeOwned>(
104        &self,
105        query: &str,
106        variables: Value,
107    ) -> Result<T> {
108        let body = json!({
109            "query": query,
110            "variables": variables,
111        });
112
113        debug!(url = %self.api_url, "fireflies graphql request");
114
115        let response = self
116            .http
117            .post(&self.api_url)
118            .header(
119                "Authorization",
120                format!("Bearer {}", self.api_key.expose_secret()),
121            )
122            .header("Content-Type", "application/json")
123            .json(&body)
124            .send()
125            .await
126            .map_err(|e| Error::Network(e.to_string()))?;
127
128        let status = response.status();
129        if status == reqwest::StatusCode::UNAUTHORIZED {
130            return Err(Error::Unauthorized("Invalid Fireflies API key".to_string()));
131        }
132        if !status.is_success() {
133            let text = response.text().await.unwrap_or_default();
134            return Err(Error::Api {
135                status: status.as_u16(),
136                message: text,
137            });
138        }
139
140        let gql_response: GraphQLResponse<T> = response
141            .json()
142            .await
143            .map_err(|e| Error::InvalidData(e.to_string()))?;
144
145        if let Some(errors) = gql_response.errors {
146            let messages: Vec<String> = errors.into_iter().map(|e| e.message).collect();
147            return Err(Error::Api {
148                status: 200,
149                message: messages.join("; "),
150            });
151        }
152
153        gql_response
154            .data
155            .ok_or_else(|| Error::InvalidData("Fireflies API returned no data".to_string()))
156    }
157}
158
159#[async_trait]
160impl MeetingNotesProvider for FirefliesClient {
161    fn provider_name(&self) -> &'static str {
162        "fireflies"
163    }
164
165    async fn get_meetings(&self, filter: MeetingFilter) -> Result<ProviderResult<MeetingNote>> {
166        let variables = build_filter_variables(&filter);
167        let data: TranscriptsData = self.graphql(GET_TRANSCRIPTS_QUERY, variables).await?;
168        Ok(data
169            .transcripts
170            .into_iter()
171            .map(convert_transcript)
172            .collect::<Vec<_>>()
173            .into())
174    }
175
176    async fn get_transcript(&self, meeting_id: &str) -> Result<MeetingTranscript> {
177        let variables = json!({ "transcriptId": meeting_id });
178        let data: TranscriptData = self.graphql(GET_TRANSCRIPT_QUERY, variables).await?;
179
180        let transcript = data.transcript.ok_or_else(|| {
181            Error::NotFound(format!("Meeting transcript not found: {meeting_id}"))
182        })?;
183
184        let speakers = transcript.speakers.as_deref().unwrap_or(&[]);
185        // Build a HashMap for O(1) speaker name lookup instead of O(n) linear scan
186        let speaker_map: std::collections::HashMap<String, String> = speakers
187            .iter()
188            .filter_map(|sp| {
189                let id = match &sp.id {
190                    Some(Value::String(v)) => v.clone(),
191                    Some(Value::Number(n)) => n.to_string(),
192                    _ => return None,
193                };
194                sp.name.as_ref().map(|name| (id, name.clone()))
195            })
196            .collect();
197        let sentences = transcript
198            .sentences
199            .unwrap_or_default()
200            .into_iter()
201            .map(|s| {
202                let speaker_id = match &s.speaker_id {
203                    Some(Value::String(id)) => id.clone(),
204                    Some(Value::Number(n)) => n.to_string(),
205                    _ => String::new(),
206                };
207                // Skip speaker lookup when speaker_id is empty
208                let speaker_name = if speaker_id.is_empty() {
209                    None
210                } else {
211                    speaker_map.get(&speaker_id).cloned()
212                };
213
214                TranscriptSentence {
215                    speaker_id,
216                    speaker_name,
217                    text: s.text.unwrap_or_default(),
218                    start_time: s.start_time.unwrap_or(0.0),
219                    end_time: s.end_time.unwrap_or(0.0),
220                }
221            })
222            .collect();
223
224        Ok(MeetingTranscript {
225            meeting_id: transcript.id,
226            title: transcript.title,
227            sentences,
228        })
229    }
230
231    async fn search_meetings(
232        &self,
233        query: &str,
234        filter: MeetingFilter,
235    ) -> Result<ProviderResult<MeetingNote>> {
236        let mut variables = build_filter_variables(&filter);
237        if let Some(obj) = variables.as_object_mut() {
238            obj.insert("keyword".into(), Value::String(query.to_string()));
239        }
240        let data: TranscriptsData = self.graphql(GET_TRANSCRIPTS_QUERY, variables).await?;
241        Ok(data
242            .transcripts
243            .into_iter()
244            .map(convert_transcript)
245            .collect::<Vec<_>>()
246            .into())
247    }
248}
249
250/// Build GraphQL variables from a MeetingFilter.
251fn build_filter_variables(filter: &MeetingFilter) -> Value {
252    let mut vars = serde_json::Map::new();
253
254    if let Some(ref keyword) = filter.keyword {
255        vars.insert("keyword".into(), Value::String(keyword.clone()));
256    }
257    if let Some(ref from) = filter.from_date {
258        vars.insert("fromDate".into(), Value::String(from.clone()));
259    }
260    if let Some(ref to) = filter.to_date {
261        vars.insert("toDate".into(), Value::String(to.clone()));
262    }
263    if let Some(ref host) = filter.host_email {
264        vars.insert("host_email".into(), Value::String(host.clone()));
265    }
266    if let Some(ref participants) = filter.participants {
267        vars.insert(
268            "participants".into(),
269            Value::Array(
270                participants
271                    .iter()
272                    .map(|p| Value::String(p.clone()))
273                    .collect(),
274            ),
275        );
276    }
277    vars.insert(
278        "limit".into(),
279        Value::Number(filter.limit.unwrap_or(50).into()),
280    );
281    if let Some(skip) = filter.skip {
282        vars.insert("skip".into(), Value::Number(skip.into()));
283    }
284
285    Value::Object(vars)
286}
287
288/// Convert a Fireflies API transcript to a unified MeetingNote.
289fn convert_transcript(t: FirefliesTranscript) -> MeetingNote {
290    let meeting_date = t.date.and_then(|d| parse_date_value(&d));
291
292    let participants: Vec<String> = t
293        .meeting_attendees
294        .unwrap_or_default()
295        .into_iter()
296        .filter_map(|a| a.email.or(a.name).or(a.display_name))
297        .collect();
298
299    let speakers: Vec<MeetingSpeaker> = t
300        .speakers
301        .unwrap_or_default()
302        .into_iter()
303        .map(|s| {
304            let id = match &s.id {
305                Some(Value::String(v)) => v.clone(),
306                Some(Value::Number(n)) => n.to_string(),
307                _ => String::new(),
308            };
309            MeetingSpeaker {
310                id,
311                name: s.name.unwrap_or_default(),
312            }
313        })
314        .collect();
315
316    // Fireflies duration is in MINUTES, convert to seconds
317    let duration_seconds = t.duration.map(|d| (d * 60.0) as u64);
318
319    let summary_ref = t.summary.as_ref();
320    let action_items = summary_ref
321        .and_then(|s| s.action_items.as_ref())
322        .map(parse_array_or_string)
323        .unwrap_or_default();
324    let keywords = summary_ref
325        .and_then(|s| s.keywords.as_ref())
326        .map(parse_array_or_string)
327        .unwrap_or_default();
328    let topics_discussed = summary_ref
329        .and_then(|s| s.topics_discussed.as_ref())
330        .map(parse_array_or_string)
331        .unwrap_or_default();
332    let meeting_type = summary_ref.and_then(|s| s.meeting_type.clone());
333    let summary_text = summary_ref.and_then(|s| s.overview.clone().or(s.short_summary.clone()));
334
335    MeetingNote {
336        id: t.id,
337        title: t.title.unwrap_or_default(),
338        meeting_date,
339        duration_seconds,
340        host_email: t.host_email,
341        organizer_email: t.organizer_email,
342        participants,
343        speakers,
344        action_items,
345        keywords,
346        topics_discussed,
347        meeting_type,
348        summary: summary_text,
349        transcript_url: t.transcript_url,
350        audio_url: t.audio_url,
351        video_url: t.video_url,
352        meeting_link: t.meeting_link,
353    }
354}
355
356/// Parse a date value that can be either an ISO string or Unix timestamp in milliseconds.
357fn parse_date_value(value: &Value) -> Option<String> {
358    match value {
359        Value::String(s) => Some(s.clone()),
360        Value::Number(n) => {
361            // Unix timestamp in milliseconds → ISO 8601 string
362            let ms = n.as_f64()?;
363            let secs = (ms / 1000.0) as i64;
364            let dt = chrono::DateTime::from_timestamp(secs, 0)?;
365            Some(dt.to_rfc3339())
366        }
367        _ => None,
368    }
369}
370
371/// Parse a Fireflies field that can be either a JSON array or a markdown string.
372fn parse_array_or_string(value: &Value) -> Vec<String> {
373    match value {
374        Value::Array(arr) => arr
375            .iter()
376            .filter_map(|v| v.as_str().map(|s| s.to_string()))
377            .filter(|s| !s.is_empty())
378            .collect(),
379        Value::String(s) => s
380            .lines()
381            .map(|line| {
382                let trimmed = line.trim();
383                // Strip bullet prefixes, then normalize whitespace before stripping bold
384                let stripped = trimmed
385                    .trim_start_matches('-')
386                    .trim_start_matches('*')
387                    .trim()
388                    .trim_start_matches("## ")
389                    .trim();
390                // Strip bold markers (**text**)
391                let stripped = stripped.strip_prefix("**").unwrap_or(stripped);
392                let stripped = stripped.strip_suffix("**").unwrap_or(stripped);
393                stripped.trim().to_string()
394            })
395            .filter(|s| !s.is_empty())
396            .collect(),
397        _ => vec![],
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    fn key(s: &str) -> SecretString {
406        SecretString::from(s.to_string())
407    }
408
409    #[test]
410    fn test_parse_array_or_string_with_array() {
411        let val = json!(["item1", "item2", "item3"]);
412        let result = parse_array_or_string(&val);
413        assert_eq!(result, vec!["item1", "item2", "item3"]);
414    }
415
416    #[test]
417    fn test_parse_array_or_string_with_markdown() {
418        let val = json!("**Actions**\n- Review PR\n- Update docs\n");
419        let result = parse_array_or_string(&val);
420        assert_eq!(result, vec!["Actions", "Review PR", "Update docs"]);
421    }
422
423    #[test]
424    fn test_parse_array_or_string_with_null() {
425        let val = json!(null);
426        let result = parse_array_or_string(&val);
427        assert!(result.is_empty());
428    }
429
430    #[test]
431    fn test_convert_transcript_duration_conversion() {
432        let t = FirefliesTranscript {
433            id: "test-id".into(),
434            title: Some("Test Meeting".into()),
435            date: Some(Value::String("2025-01-15T10:00:00Z".into())),
436            duration: Some(45.5), // 45.5 minutes
437            host_email: None,
438            organizer_email: None,
439            meeting_attendees: None,
440            speakers: None,
441            transcript_url: None,
442            audio_url: None,
443            video_url: None,
444            meeting_link: None,
445            summary: None,
446            sentences: None,
447        };
448
449        let note = convert_transcript(t);
450        assert_eq!(note.duration_seconds, Some(2730)); // 45.5 * 60 = 2730
451    }
452
453    #[test]
454    fn test_convert_transcript_with_attendees_and_speakers() {
455        let t = FirefliesTranscript {
456            id: "m-123".into(),
457            title: Some("Sprint Planning".into()),
458            date: None,
459            duration: None,
460            host_email: Some("host@example.com".into()),
461            organizer_email: Some("org@example.com".into()),
462            meeting_attendees: Some(vec![
463                FirefliesAttendee {
464                    display_name: None,
465                    email: Some("alice@example.com".into()),
466                    name: None,
467                },
468                FirefliesAttendee {
469                    display_name: Some("Bob".into()),
470                    email: None,
471                    name: None,
472                },
473            ]),
474            speakers: Some(vec![
475                FirefliesSpeaker {
476                    id: Some("1".into()),
477                    name: Some("Alice".into()),
478                },
479                FirefliesSpeaker {
480                    id: Some("2".into()),
481                    name: Some("Bob".into()),
482                },
483            ]),
484            transcript_url: None,
485            audio_url: None,
486            video_url: None,
487            meeting_link: None,
488            summary: Some(FirefliesSummary {
489                keywords: Some(json!(["rust", "migration"])),
490                action_items: Some(json!("- Review PR\n- Update docs")),
491                topics_discussed: None,
492                meeting_type: Some("planning".into()),
493                overview: Some("Team discussed migration plan.".into()),
494                short_summary: None,
495            }),
496            sentences: None,
497        };
498
499        let note = convert_transcript(t);
500        assert_eq!(note.id, "m-123");
501        assert_eq!(note.title, "Sprint Planning");
502        assert_eq!(note.host_email, Some("host@example.com".into()));
503        assert_eq!(
504            note.participants,
505            vec!["alice@example.com".to_string(), "Bob".to_string()]
506        );
507        assert_eq!(note.speakers.len(), 2);
508        assert_eq!(note.speakers[0].name, "Alice");
509        assert_eq!(note.keywords, vec!["rust", "migration"]);
510        assert_eq!(note.action_items, vec!["Review PR", "Update docs"]);
511        assert_eq!(note.meeting_type, Some("planning".into()));
512        assert_eq!(note.summary, Some("Team discussed migration plan.".into()));
513    }
514
515    #[test]
516    fn test_convert_transcript_no_duration() {
517        let t = FirefliesTranscript {
518            id: "no-dur".into(),
519            title: None,
520            date: None,
521            duration: None,
522            host_email: None,
523            organizer_email: None,
524            meeting_attendees: None,
525            speakers: None,
526            transcript_url: None,
527            audio_url: None,
528            video_url: None,
529            meeting_link: None,
530            summary: None,
531            sentences: None,
532        };
533
534        let note = convert_transcript(t);
535        assert_eq!(note.duration_seconds, None);
536        assert!(note.title.is_empty());
537    }
538
539    #[test]
540    fn test_parse_array_or_string_with_empty_array() {
541        let val = json!([]);
542        let result = parse_array_or_string(&val);
543        assert!(result.is_empty());
544    }
545
546    #[test]
547    fn test_parse_array_or_string_filters_empty_strings() {
548        let val = json!(["item1", "", "item2"]);
549        let result = parse_array_or_string(&val);
550        assert_eq!(result, vec!["item1", "item2"]);
551    }
552
553    #[test]
554    fn test_build_filter_variables_empty() {
555        let filter = MeetingFilter::default();
556        let vars = build_filter_variables(&filter);
557        let obj = vars.as_object().unwrap();
558        assert_eq!(obj.get("limit").unwrap(), &json!(50));
559        assert!(obj.get("keyword").is_none());
560        assert!(obj.get("fromDate").is_none());
561    }
562
563    #[test]
564    fn test_build_filter_variables_full() {
565        let filter = MeetingFilter {
566            keyword: Some("sprint".into()),
567            from_date: Some("2025-01-01".into()),
568            to_date: Some("2025-12-31".into()),
569            participants: Some(vec!["alice@ex.com".into()]),
570            host_email: Some("host@ex.com".into()),
571            limit: Some(10),
572            skip: Some(5),
573        };
574        let vars = build_filter_variables(&filter);
575        let obj = vars.as_object().unwrap();
576        assert_eq!(obj["keyword"], json!("sprint"));
577        assert_eq!(obj["fromDate"], json!("2025-01-01"));
578        assert_eq!(obj["toDate"], json!("2025-12-31"));
579        assert_eq!(obj["host_email"], json!("host@ex.com"));
580        assert_eq!(obj["participants"], json!(["alice@ex.com"]));
581        assert_eq!(obj["limit"], json!(10));
582        assert_eq!(obj["skip"], json!(5));
583    }
584
585    #[test]
586    fn test_fireflies_client_new() {
587        let client = FirefliesClient::new(key("test-key"));
588        assert_eq!(client.provider_name(), "fireflies");
589    }
590
591    #[test]
592    fn test_with_api_url_overrides_endpoint() {
593        let client = FirefliesClient::new(key("k")).with_api_url("http://localhost:1234/gql");
594        assert_eq!(client.api_url, "http://localhost:1234/gql");
595    }
596
597    // ===========================================================================
598    // httpmock integration tests — exercise the GraphQL request path end-to-end.
599    // ===========================================================================
600
601    mod integration {
602        use super::*;
603        use httpmock::prelude::*;
604
605        fn key(s: &str) -> SecretString {
606            SecretString::from(s.to_string())
607        }
608
609        fn mock_client(server: &MockServer) -> FirefliesClient {
610            FirefliesClient::new(key("test-token")).with_api_url(server.url("/gql"))
611        }
612
613        #[tokio::test]
614        async fn get_meetings_returns_parsed_transcripts() {
615            let server = MockServer::start();
616            server.mock(|when, then| {
617                when.method(POST)
618                    .path("/gql")
619                    .header("authorization", "Bearer test-token");
620                then.status(200).json_body(json!({
621                    "data": {
622                        "transcripts": [
623                            {
624                                "id": "t1",
625                                "title": "Sprint planning",
626                                "date": "2025-04-15T10:00:00Z",
627                                "duration": 45,
628                                "host_email": "host@ex.com",
629                                "organizer_email": null,
630                                "meeting_attendees": [
631                                    { "displayName": "Alice", "email": "alice@ex.com", "name": "Alice" }
632                                ],
633                                "speakers": [{ "id": "1", "name": "Alice" }],
634                                "transcript_url": null,
635                                "audio_url": null,
636                                "video_url": null,
637                                "meeting_link": null,
638                                "summary": {
639                                    "keywords": [],
640                                    "action_items": null,
641                                    "topics_discussed": [],
642                                    "meeting_type": null,
643                                    "overview": null,
644                                    "short_summary": null
645                                }
646                            }
647                        ]
648                    }
649                }));
650            });
651
652            let client = mock_client(&server);
653            let result = client.get_meetings(MeetingFilter::default()).await.unwrap();
654            assert_eq!(result.items.len(), 1);
655            assert_eq!(result.items[0].title, "Sprint planning");
656        }
657
658        #[tokio::test]
659        async fn get_transcript_maps_sentences_to_speaker_names() {
660            let server = MockServer::start();
661            server.mock(|when, then| {
662                when.method(POST).path("/gql");
663                then.status(200).json_body(json!({
664                    "data": {
665                        "transcript": {
666                            "id": "t1",
667                            "title": "Sprint",
668                            "date": "2025-04-15T10:00:00Z",
669                            "duration": 45,
670                            "speakers": [
671                                { "id": "1", "name": "Alice" },
672                                { "id": 2, "name": "Bob" }
673                            ],
674                            "sentences": [
675                                { "speaker_id": "1", "text": "Hi", "start_time": 0.0, "end_time": 1.0 },
676                                { "speaker_id": 2, "text": "Hey", "start_time": 1.5, "end_time": 2.5 },
677                                { "speaker_id": null, "text": "—", "start_time": 3.0, "end_time": 3.2 }
678                            ]
679                        }
680                    }
681                }));
682            });
683
684            let client = mock_client(&server);
685            let t = client.get_transcript("t1").await.unwrap();
686            assert_eq!(t.meeting_id, "t1");
687            assert_eq!(t.sentences.len(), 3);
688            // Both string-id and number-id speakers resolved.
689            assert_eq!(t.sentences[0].speaker_name.as_deref(), Some("Alice"));
690            assert_eq!(t.sentences[1].speaker_name.as_deref(), Some("Bob"));
691            // Unknown (null) speaker_id -> no name attached, empty id string.
692            assert!(t.sentences[2].speaker_name.is_none());
693            assert_eq!(t.sentences[2].speaker_id, "");
694        }
695
696        #[tokio::test]
697        async fn get_transcript_missing_returns_not_found() {
698            let server = MockServer::start();
699            server.mock(|when, then| {
700                when.method(POST).path("/gql");
701                then.status(200)
702                    .json_body(json!({ "data": { "transcript": null } }));
703            });
704            let client = mock_client(&server);
705            let err = client.get_transcript("missing").await.unwrap_err();
706            assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
707        }
708
709        #[tokio::test]
710        async fn graphql_401_becomes_unauthorized_error() {
711            let server = MockServer::start();
712            server.mock(|when, then| {
713                when.method(POST).path("/gql");
714                then.status(401).body("nope");
715            });
716            let client = mock_client(&server);
717            let err = client
718                .get_meetings(MeetingFilter::default())
719                .await
720                .unwrap_err();
721            assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
722        }
723
724        #[tokio::test]
725        async fn graphql_500_becomes_api_error_with_status() {
726            let server = MockServer::start();
727            server.mock(|when, then| {
728                when.method(POST).path("/gql");
729                then.status(500).body("boom");
730            });
731            let client = mock_client(&server);
732            let err = client.get_transcript("anything").await.unwrap_err();
733            match err {
734                Error::Api { status, message } => {
735                    assert_eq!(status, 500);
736                    assert!(message.contains("boom"));
737                }
738                other => panic!("expected Api, got {other:?}"),
739            }
740        }
741
742        #[tokio::test]
743        async fn graphql_errors_field_surfaces_as_api_error() {
744            let server = MockServer::start();
745            server.mock(|when, then| {
746                when.method(POST).path("/gql");
747                then.status(200).json_body(json!({
748                    "errors": [
749                        {"message": "rate limited"},
750                        {"message": "again"}
751                    ]
752                }));
753            });
754            let client = mock_client(&server);
755            let err = client
756                .get_meetings(MeetingFilter::default())
757                .await
758                .unwrap_err();
759            match err {
760                Error::Api { status, message } => {
761                    assert_eq!(status, 200);
762                    assert!(message.contains("rate limited"));
763                    assert!(message.contains("again"));
764                }
765                other => panic!("expected Api, got {other:?}"),
766            }
767        }
768
769        #[tokio::test]
770        async fn graphql_missing_data_is_invalid_data_error() {
771            let server = MockServer::start();
772            server.mock(|when, then| {
773                when.method(POST).path("/gql");
774                // 200 OK but no `data` and no `errors` — malformed
775                // server response. Client must not panic.
776                then.status(200).json_body(json!({}));
777            });
778            let client = mock_client(&server);
779            let err = client
780                .get_meetings(MeetingFilter::default())
781                .await
782                .unwrap_err();
783            assert!(matches!(err, Error::InvalidData(_)), "got {err:?}");
784        }
785
786        #[tokio::test]
787        async fn search_meetings_adds_keyword_and_hits_endpoint() {
788            let server = MockServer::start();
789            server.mock(|when, then| {
790                when.method(POST)
791                    .path("/gql")
792                    .body_includes("\"keyword\":\"rollback\"");
793                then.status(200).json_body(json!({
794                    "data": { "transcripts": [] }
795                }));
796            });
797            let client = mock_client(&server);
798            let result = client
799                .search_meetings("rollback", MeetingFilter::default())
800                .await
801                .unwrap();
802            assert_eq!(result.items.len(), 0);
803        }
804    }
805}