|
| 1 | +use std::collections::BTreeSet; |
| 2 | + |
| 3 | +use chrono::{DateTime, TimeDelta, Utc}; |
| 4 | +use locality_core::LocalityError; |
| 5 | +use locality_granola::{ |
| 6 | + GranolaApi, GranolaContentKind, GranolaNativeBundle, GranolaNote, HttpGranolaApiClient, |
| 7 | + render_granola_note, |
| 8 | +}; |
| 9 | + |
| 10 | +const LIVE_KEY_ENV: &str = "GRANOLA_API_KEY"; |
| 11 | +const LIVE_NOTE_ENV: &str = "LOCALITY_GRANOLA_LIVE_NOTE_ID"; |
| 12 | +const NOTE_SCAN_LIMIT: usize = 12; |
| 13 | + |
| 14 | +#[test] |
| 15 | +#[ignore = "requires GRANOLA_API_KEY; reads real Granola notes without mutating them"] |
| 16 | +fn live_public_api_paginates_fetches_transcript_and_renders_canonical_files() { |
| 17 | + let api_key = required_secret_env(LIVE_KEY_ENV); |
| 18 | + let api = HttpGranolaApiClient::new(api_key); |
| 19 | + |
| 20 | + let first_page = api |
| 21 | + .list_notes(None, 30, None, None, None) |
| 22 | + .unwrap_or_else(|error| panic!("Granola list-notes request failed: {error}")); |
| 23 | + assert!( |
| 24 | + !first_page.notes.is_empty(), |
| 25 | + "Granola list-notes returned no accessible notes" |
| 26 | + ); |
| 27 | + assert!( |
| 28 | + first_page.notes.len() <= 30, |
| 29 | + "Granola exceeded page_size=30" |
| 30 | + ); |
| 31 | + |
| 32 | + if first_page.has_more { |
| 33 | + let cursor = first_page |
| 34 | + .cursor |
| 35 | + .as_deref() |
| 36 | + .filter(|cursor| !cursor.is_empty()) |
| 37 | + .expect("Granola reported has_more without a cursor"); |
| 38 | + let second_page = api |
| 39 | + .list_notes(Some(cursor), 30, None, None, None) |
| 40 | + .unwrap_or_else(|error| panic!("Granola cursor pagination failed: {error}")); |
| 41 | + assert!( |
| 42 | + !second_page.notes.is_empty(), |
| 43 | + "Granola cursor returned an empty follow-up page" |
| 44 | + ); |
| 45 | + let first_ids = first_page |
| 46 | + .notes |
| 47 | + .iter() |
| 48 | + .map(|note| note.id.as_str()) |
| 49 | + .collect::<BTreeSet<_>>(); |
| 50 | + assert!( |
| 51 | + second_page |
| 52 | + .notes |
| 53 | + .iter() |
| 54 | + .all(|note| !first_ids.contains(note.id.as_str())), |
| 55 | + "Granola cursor pagination repeated a note from the first page" |
| 56 | + ); |
| 57 | + } |
| 58 | + |
| 59 | + let note = live_note_with_transcript(&api, &first_page.notes); |
| 60 | + assert!(!note.id.trim().is_empty(), "Granola note id was empty"); |
| 61 | + assert_eq!( |
| 62 | + note.object, "note", |
| 63 | + "Granola returned an unexpected object type" |
| 64 | + ); |
| 65 | + assert!( |
| 66 | + chrono::DateTime::parse_from_rfc3339(¬e.created_at).is_ok(), |
| 67 | + "Granola note created_at was not RFC 3339" |
| 68 | + ); |
| 69 | + assert!( |
| 70 | + chrono::DateTime::parse_from_rfc3339(¬e.updated_at).is_ok(), |
| 71 | + "Granola note updated_at was not RFC 3339" |
| 72 | + ); |
| 73 | + assert!( |
| 74 | + note.web_url.starts_with("https://"), |
| 75 | + "Granola note web_url was not HTTPS" |
| 76 | + ); |
| 77 | + |
| 78 | + let metadata_only = api.get_note(¬e.id, false).unwrap_or_else(|error| { |
| 79 | + panic!("Granola get-note request without transcript failed: {error}") |
| 80 | + }); |
| 81 | + assert_eq!( |
| 82 | + metadata_only.id, note.id, |
| 83 | + "Granola get-note variants returned different note ids" |
| 84 | + ); |
| 85 | + assert!( |
| 86 | + metadata_only.title == note.title |
| 87 | + && metadata_only.created_at == note.created_at |
| 88 | + && metadata_only.updated_at == note.updated_at |
| 89 | + && metadata_only.summary_text == note.summary_text |
| 90 | + && metadata_only.summary_markdown == note.summary_markdown, |
| 91 | + "Granola transcript inclusion changed note metadata or summary fields" |
| 92 | + ); |
| 93 | + |
| 94 | + let summary = render_granola_note(&GranolaNativeBundle { |
| 95 | + content_kind: GranolaContentKind::Summary, |
| 96 | + note: note.clone(), |
| 97 | + }) |
| 98 | + .expect("render live Granola summary"); |
| 99 | + let transcript = render_granola_note(&GranolaNativeBundle { |
| 100 | + content_kind: GranolaContentKind::Transcript, |
| 101 | + note: note.clone(), |
| 102 | + }) |
| 103 | + .expect("render live Granola transcript"); |
| 104 | + |
| 105 | + assert_frontmatter_contract(&summary.frontmatter, ¬e.id, "summary"); |
| 106 | + assert_frontmatter_contract(&transcript.frontmatter, ¬e.id, "transcript"); |
| 107 | + assert!( |
| 108 | + !summary.body.trim().is_empty(), |
| 109 | + "rendered Granola summary body was empty" |
| 110 | + ); |
| 111 | + let transcript_chunk_count = note.transcript.as_ref().map_or(0, Vec::len); |
| 112 | + assert_compact_transcript_contract(&transcript.body, transcript_chunk_count); |
| 113 | + |
| 114 | + let note_updated_at = DateTime::parse_from_rfc3339(¬e.updated_at) |
| 115 | + .expect("live Granola note updated_at was already validated") |
| 116 | + .with_timezone(&Utc); |
| 117 | + let updated_after = (note_updated_at - TimeDelta::days(2)) |
| 118 | + .format("%Y-%m-%d") |
| 119 | + .to_string(); |
| 120 | + api.list_notes(None, 1, None, None, Some(&updated_after)) |
| 121 | + .unwrap_or_else(|error| { |
| 122 | + panic!("Granola incremental updated_after request failed: {error}") |
| 123 | + }); |
| 124 | +} |
| 125 | + |
| 126 | +fn live_note_with_transcript( |
| 127 | + api: &HttpGranolaApiClient, |
| 128 | + summaries: &[locality_granola::GranolaNoteSummary], |
| 129 | +) -> GranolaNote { |
| 130 | + if let Ok(note_id) = std::env::var(LIVE_NOTE_ENV) |
| 131 | + && !note_id.trim().is_empty() |
| 132 | + { |
| 133 | + match api.get_note(note_id.trim(), true) { |
| 134 | + Ok(note) |
| 135 | + if note |
| 136 | + .transcript |
| 137 | + .as_ref() |
| 138 | + .is_some_and(|chunks| !chunks.is_empty()) |
| 139 | + && has_summary(¬e) => |
| 140 | + { |
| 141 | + return note; |
| 142 | + } |
| 143 | + Ok(_) | Err(LocalityError::RemoteNotFound(_)) => {} |
| 144 | + Err(error) => panic!("Configured Granola live note could not be fetched: {error}"), |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + for summary in summaries.iter().take(NOTE_SCAN_LIMIT) { |
| 149 | + let note = api |
| 150 | + .get_note(&summary.id, true) |
| 151 | + .unwrap_or_else(|error| panic!("Granola transcript fetch failed: {error}")); |
| 152 | + if note |
| 153 | + .transcript |
| 154 | + .as_ref() |
| 155 | + .is_some_and(|chunks| !chunks.is_empty()) |
| 156 | + && has_summary(¬e) |
| 157 | + { |
| 158 | + return note; |
| 159 | + } |
| 160 | + } |
| 161 | + panic!( |
| 162 | + "None of the first {NOTE_SCAN_LIMIT} Granola notes had both a summary and retained transcript; set {LIVE_NOTE_ENV} to a stable qualifying note" |
| 163 | + ); |
| 164 | +} |
| 165 | + |
| 166 | +fn has_summary(note: &GranolaNote) -> bool { |
| 167 | + note.summary_markdown |
| 168 | + .as_deref() |
| 169 | + .is_some_and(|summary| !summary.trim().is_empty()) |
| 170 | + || !note.summary_text.trim().is_empty() |
| 171 | +} |
| 172 | + |
| 173 | +fn assert_frontmatter_contract(frontmatter: &str, note_id: &str, kind: &str) { |
| 174 | + assert!( |
| 175 | + frontmatter.contains(" connector: granola\n"), |
| 176 | + "Granola frontmatter omitted connector identity" |
| 177 | + ); |
| 178 | + assert!( |
| 179 | + frontmatter.contains(&format!(" content_kind: {kind}\n")), |
| 180 | + "Granola frontmatter used the wrong content kind" |
| 181 | + ); |
| 182 | + assert!( |
| 183 | + frontmatter.contains(note_id), |
| 184 | + "Granola frontmatter omitted the durable note id" |
| 185 | + ); |
| 186 | + for field in [ |
| 187 | + "web_url", |
| 188 | + "created_at", |
| 189 | + "updated_at", |
| 190 | + "owner", |
| 191 | + "attendees", |
| 192 | + "folders", |
| 193 | + ] { |
| 194 | + assert!( |
| 195 | + frontmatter.contains(&format!(" {field}:")), |
| 196 | + "Granola frontmatter omitted required field `{field}`" |
| 197 | + ); |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +fn assert_compact_transcript_contract(body: &str, expected_heading_count: usize) { |
| 202 | + let headings = body |
| 203 | + .lines() |
| 204 | + .filter(|line| line.starts_with("**Me") || line.starts_with("**Them")) |
| 205 | + .collect::<Vec<_>>(); |
| 206 | + assert!( |
| 207 | + !headings.is_empty(), |
| 208 | + "rendered Granola transcript had no speaker turns" |
| 209 | + ); |
| 210 | + assert_eq!( |
| 211 | + headings.len(), |
| 212 | + expected_heading_count, |
| 213 | + "rendered Granola transcript did not preserve one heading per chunk" |
| 214 | + ); |
| 215 | + for heading in headings { |
| 216 | + let heading = heading |
| 217 | + .strip_prefix("**") |
| 218 | + .and_then(|value| value.strip_suffix("**")) |
| 219 | + .expect("Granola transcript heading was not bold Markdown"); |
| 220 | + let (speaker, time) = heading |
| 221 | + .split_once(" · ") |
| 222 | + .expect("Granola transcript heading was not speaker-first"); |
| 223 | + assert!( |
| 224 | + speaker == "Me" |
| 225 | + || speaker == "Them" |
| 226 | + || (speaker.starts_with("Me (") && speaker.ends_with(')')) |
| 227 | + || (speaker.starts_with("Them (") && speaker.ends_with(')')), |
| 228 | + "Granola transcript heading did not lead with Me or Them" |
| 229 | + ); |
| 230 | + let normalized_speaker = speaker.to_ascii_lowercase(); |
| 231 | + assert!( |
| 232 | + !normalized_speaker.ends_with(" (microphone)") |
| 233 | + && !normalized_speaker.ends_with(" (speaker)"), |
| 234 | + "Granola transcript heading exposed the repeated capture source" |
| 235 | + ); |
| 236 | + let time = time |
| 237 | + .strip_suffix(" UTC") |
| 238 | + .expect("Granola transcript heading omitted UTC"); |
| 239 | + for part in time.split('–') { |
| 240 | + assert_compact_time(part); |
| 241 | + } |
| 242 | + } |
| 243 | +} |
| 244 | + |
| 245 | +fn assert_compact_time(value: &str) { |
| 246 | + let parts = value.split(':').collect::<Vec<_>>(); |
| 247 | + assert_eq!(parts.len(), 3, "Granola transcript time was not HH:MM:SS"); |
| 248 | + assert!( |
| 249 | + parts |
| 250 | + .iter() |
| 251 | + .all(|part| part.len() == 2 && part.chars().all(|character| character.is_ascii_digit())), |
| 252 | + "Granola transcript time was not compact numeric HH:MM:SS" |
| 253 | + ); |
| 254 | +} |
| 255 | + |
| 256 | +fn required_secret_env(name: &str) -> String { |
| 257 | + std::env::var(name) |
| 258 | + .ok() |
| 259 | + .filter(|value| !value.trim().is_empty()) |
| 260 | + .unwrap_or_else(|| panic!("set {name} to run the live Granola integrity test")) |
| 261 | +} |
0 commit comments