1use std::path::Path;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14
15use crate::cache::{Cache, acquire_lock_async};
16use crate::error::{AppError, Result};
17use crate::usage::{CursorCreditGrant, CursorSnapshot};
18use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
19
20use super::db;
21use super::types::{self, UsageSummary};
22
23pub const BASE_URL: &str = "https://cursor.com";
24pub const CREDITS_URL: &str =
27 "https://api2.cursor.sh/aiserver.v1.DashboardService/GetClientVisibleCreditGrants";
28const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
29const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
30const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
33 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
34
35#[derive(Debug, Clone)]
36pub struct Endpoints {
37 pub summary: String,
38 pub credits: String,
39}
40
41impl Default for Endpoints {
42 fn default() -> Self {
43 Self {
44 summary: format!("{BASE_URL}/api/usage-summary"),
45 credits: CREDITS_URL.into(),
46 }
47 }
48}
49
50pub type FetchOutcome = crate::outcome::Outcome<CursorSnapshot>;
53
54pub async fn fetch_snapshot(
61 client: &reqwest::Client,
62 db_path: &Path,
63 agent_auth_path: &Path,
64 cache: &Cache,
65 endpoints: &Endpoints,
66 cache_ttl: Duration,
67) -> Result<FetchOutcome> {
68 fetch_snapshot_at(
69 client,
70 db_path,
71 agent_auth_path,
72 cache,
73 endpoints,
74 cache_ttl,
75 Utc::now(),
76 )
77 .await
78}
79
80async fn fetch_snapshot_at(
83 client: &reqwest::Client,
84 db_path: &Path,
85 agent_auth_path: &Path,
86 cache: &Cache,
87 endpoints: &Endpoints,
88 cache_ttl: Duration,
89 now: DateTime<Utc>,
90) -> Result<FetchOutcome> {
91 cache.ensure_dir()?;
92 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
93
94 let token = db::resolve_access_token(db_path, agent_auth_path)?;
98 let auth = db::session_auth(&token)?;
99
100 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
104 && payload_records_credits(&bytes)
105 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &auth.account_key, now)
106 {
107 return Ok(outcome);
108 }
109
110 match fetch_live(client, endpoints, &auth, &token).await {
111 Ok(snap) => {
112 let bytes = serde_json::to_vec(&snap_to_json(&snap, &auth.account_key))?;
113 cache.write_payload(&bytes)?;
114 Ok(crate::outcome::Outcome::fresh(snap))
115 }
116 Err(e) if e.is_transient() => fallback_silent(cache, &auth.account_key, now, e),
117 Err(e) => {
118 cache.mark_stale();
119 if let Some((code, msg)) = error_to_pair(&e) {
120 cache.write_last_error(code, &msg);
121 }
122 fallback_with_error(cache, &auth.account_key, now, e)
123 }
124 }
125}
126
127fn fallback_silent(
128 cache: &Cache,
129 account: &str,
130 now: DateTime<Utc>,
131 original: AppError,
132) -> Result<FetchOutcome> {
133 crate::outcome::fallback(cache, None, original, |bytes| {
134 parse_cache_at(bytes, account, now)
135 })
136}
137
138fn fallback_with_error(
139 cache: &Cache,
140 account: &str,
141 now: DateTime<Utc>,
142 original: AppError,
143) -> Result<FetchOutcome> {
144 let last_error = error_to_pair(&original);
145 crate::outcome::fallback(cache, last_error, original, |bytes| {
146 parse_cache_at(bytes, account, now)
147 })
148}
149
150fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
154 match e {
155 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
156 Some((*status, "Cursor authentication failed".into()))
157 }
158 AppError::Http { status, body } => Some((*status, body.clone())),
159 e => Some((0, e.to_string())),
160 }
161}
162
163fn reuse_cache(
164 bytes: &[u8],
165 cache: &Cache,
166 stale: bool,
167 account: &str,
168 now: DateTime<Utc>,
169) -> Result<FetchOutcome> {
170 let snap = parse_cache_at(bytes, account, now)?;
171 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
172}
173
174fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<CursorSnapshot> {
175 let v: serde_json::Value = serde_json::from_slice(bytes)?;
176 if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
177 return Err(AppError::Schema(
178 "cursor cache belongs to a different account; refetching".into(),
179 ));
180 }
181 let int = |key: &str| -> Result<i32> {
182 v[key]
183 .as_i64()
184 .filter(|n| *n >= 0)
185 .and_then(|n| i32::try_from(n).ok())
186 .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}")))
187 };
188 let optional_cents = |key: &str| -> Result<Option<i64>> {
189 match v.get(key) {
190 None | Some(serde_json::Value::Null) => Ok(None),
191 Some(value) => value
192 .as_i64()
193 .filter(|n| *n >= 0)
194 .map(Some)
195 .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}"))),
196 }
197 };
198 let plan = v["plan"]
199 .as_str()
200 .filter(|plan| !plan.trim().is_empty())
201 .ok_or_else(|| AppError::Schema("cursor cache: invalid plan".into()))?
202 .to_string();
203 let reset_at = parse_cache_datetime(&v["reset_at"])?
204 .ok_or_else(|| AppError::Schema("cursor cache: missing reset timestamp".into()))?;
205 if reset_at <= now {
206 return Err(AppError::Schema(
207 "cursor cache is past its billing-cycle reset; refetching".into(),
208 ));
209 }
210 Ok(CursorSnapshot {
211 plan,
212 auto_pct: int("auto_pct")?,
213 api_pct: int("api_pct")?,
214 total_pct: int("total_pct")?,
215 unlimited: v["unlimited"]
216 .as_bool()
217 .ok_or_else(|| AppError::Schema("cursor cache: invalid unlimited flag".into()))?,
218 on_demand_enabled: v["on_demand_enabled"]
219 .as_bool()
220 .ok_or_else(|| AppError::Schema("cursor cache: invalid on-demand flag".into()))?,
221 on_demand_used_cents: optional_cents("on_demand_used_cents")?,
222 on_demand_limit_cents: optional_cents("on_demand_limit_cents")?,
223 reset_at: Some(reset_at),
224 cycle_start: v
225 .get("cycle_start")
226 .and_then(|c| parse_cache_datetime(c).ok())
227 .flatten(),
228 credits: parse_cached_credits(&v),
229 })
230}
231
232fn payload_records_credits(bytes: &[u8]) -> bool {
237 serde_json::from_slice::<serde_json::Value>(bytes)
238 .ok()
239 .is_some_and(|value| value.get("credits").is_some())
240}
241
242fn parse_cached_credits(v: &serde_json::Value) -> Vec<CursorCreditGrant> {
243 let Some(items) = v.get("credits").and_then(serde_json::Value::as_array) else {
244 return Vec::new();
245 };
246 items.iter().filter_map(cached_credit).collect()
247}
248
249fn cached_credit(item: &serde_json::Value) -> Option<CursorCreditGrant> {
250 let remaining_cents = item.get("remaining_cents")?.as_i64().filter(|n| *n >= 0)?;
251 let total_cents = item.get("total_cents")?.as_i64().filter(|n| *n > 0)?;
252 let expires_at = match item.get("expires_at") {
253 None | Some(serde_json::Value::Null) => None,
254 Some(raw) => Some(parse_cache_datetime(raw).ok().flatten()?),
255 };
256 let display_name = item
257 .get("display_name")
258 .and_then(serde_json::Value::as_str)
259 .unwrap_or("")
260 .trim()
261 .chars()
262 .take(160)
263 .collect();
264 Some(CursorCreditGrant {
265 remaining_cents,
266 total_cents,
267 expires_at,
268 display_name,
269 })
270}
271
272fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
273 match v {
274 serde_json::Value::Null => Ok(None),
275 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
276 .map(|dt| Some(dt.into()))
277 .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
278 _ => Err(AppError::Schema(
279 "cursor cache: invalid reset timestamp".into(),
280 )),
281 }
282}
283
284fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
285 serde_json::json!({
286 "account": account,
287 "plan": snap.plan,
288 "auto_pct": snap.auto_pct,
289 "api_pct": snap.api_pct,
290 "total_pct": snap.total_pct,
291 "unlimited": snap.unlimited,
292 "on_demand_enabled": snap.on_demand_enabled,
293 "on_demand_used_cents": snap.on_demand_used_cents,
294 "on_demand_limit_cents": snap.on_demand_limit_cents,
295 "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
296 "cycle_start": snap.cycle_start.map(|dt| dt.to_rfc3339()),
297 "credits": snap.credits.iter().map(|grant| serde_json::json!({
298 "remaining_cents": grant.remaining_cents,
299 "total_cents": grant.total_cents,
300 "expires_at": grant.expires_at.map(|dt| dt.to_rfc3339()),
301 "display_name": grant.display_name,
302 })).collect::<Vec<_>>(),
303 })
304}
305
306async fn fetch_live(
307 client: &reqwest::Client,
308 endpoints: &Endpoints,
309 auth: &db::SessionAuth,
310 access_token: &str,
311) -> Result<CursorSnapshot> {
312 let resp = tokio::time::timeout(
315 HTTP_TIMEOUT,
316 client
317 .get(&endpoints.summary)
318 .header(
319 "Cookie",
320 format!("WorkosCursorSessionToken={}", auth.cookie_value),
321 )
322 .header("Origin", BASE_URL)
323 .header("Referer", format!("{BASE_URL}/dashboard"))
324 .header("User-Agent", BROWSER_UA)
325 .send(),
326 )
327 .await
328 .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
329
330 let status = resp.status();
331 if !status.is_success() {
332 let body = if matches!(status.as_u16(), 401 | 403) {
333 "Cursor authentication failed".into()
334 } else {
335 format!("Cursor API returned HTTP {}", status.as_u16())
336 };
337 return Err(AppError::Http {
338 status: status.as_u16(),
339 body,
340 });
341 }
342
343 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
344 let parsed: UsageSummary = serde_json::from_slice(&bytes)
345 .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
346 let mut snap = types::to_snapshot(parsed)?;
347 snap.credits = fetch_credits(client, endpoints, access_token).await;
348 Ok(snap)
349}
350
351async fn fetch_credits(
354 client: &reqwest::Client,
355 endpoints: &Endpoints,
356 access_token: &str,
357) -> Vec<CursorCreditGrant> {
358 let Ok(Ok(resp)) = tokio::time::timeout(
359 HTTP_TIMEOUT,
360 client
361 .post(&endpoints.credits)
362 .header("Content-Type", "application/json")
363 .header("Authorization", format!("Bearer {access_token}"))
364 .header("Connect-Protocol-Version", "1")
365 .body("{}")
366 .send(),
367 )
368 .await
369 else {
370 return Vec::new();
371 };
372 if !resp.status().is_success() {
373 return Vec::new();
374 }
375 let Ok(bytes) = read_body_capped(resp, MAX_BODY_BYTES).await else {
376 return Vec::new();
377 };
378 types::parse_credit_grants(&bytes)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use rusqlite::Connection;
385 use tempfile::TempDir;
386
387 fn cache_fixture() -> (TempDir, Cache) {
388 let td = TempDir::new().unwrap();
389 let cache = Cache::at(td.path().join("cursor"));
390 cache.ensure_dir().unwrap();
391 (td, cache)
392 }
393
394 fn fake_token(user_id: &str) -> String {
397 use base64::Engine;
398 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
399 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
400 .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
401 format!("{header}.{payload}.sig")
402 }
403
404 fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
405 let path = dir.path().join("state.vscdb");
406 let conn = Connection::open(&path).unwrap();
407 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
408 .unwrap();
409 conn.execute(
410 "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
411 [token],
412 )
413 .unwrap();
414 path
415 }
416
417 fn account_key(token: &str) -> String {
418 db::session_auth(token).unwrap().account_key
419 }
420
421 fn no_agent_auth() -> std::path::PathBuf {
424 std::path::PathBuf::from("/nonexistent/cursor-agent-auth.json")
425 }
426
427 fn endpoints_for(server: &mockito::Server) -> Endpoints {
428 Endpoints {
429 summary: format!("{}/api/usage-summary", server.url()),
430 credits: format!(
431 "{}/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
432 server.url()
433 ),
434 }
435 }
436
437 fn cached_snapshot(account: &str, reset_at: &str) -> Vec<u8> {
438 serde_json::to_vec(&serde_json::json!({
439 "account": account,
440 "plan": "Ultra",
441 "auto_pct": 40,
442 "api_pct": 10,
443 "total_pct": 30,
444 "unlimited": false,
445 "on_demand_enabled": false,
446 "reset_at": reset_at,
447 }))
448 .unwrap()
449 }
450
451 fn sample_json() -> String {
452 r#"{
453 "billingCycleEnd": "2099-08-04T00:35:51.000Z",
454 "membershipType": "ultra",
455 "isUnlimited": false,
456 "individualUsage": {
457 "plan": { "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
458 "onDemand": { "enabled": false }
459 }
460 }"#
461 .to_string()
462 }
463
464 #[tokio::test]
465 async fn live_fetch_reads_token_from_db_and_sends_the_session_cookie() {
466 let mut server = mockito::Server::new_async().await;
467 let token = fake_token("user_123");
468 let m = server
469 .mock("GET", "/api/usage-summary")
470 .match_header(
471 "cookie",
472 format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
473 )
474 .with_status(200)
475 .with_body(sample_json())
476 .create_async()
477 .await;
478
479 let db_dir = TempDir::new().unwrap();
480 let db_path = seed_state_db(&db_dir, &token);
481 let (_cache_dir, cache) = cache_fixture();
482 let client = reqwest::Client::new();
483 let endpoints = endpoints_for(&server);
484
485 let out = fetch_snapshot(
486 &client,
487 &db_path,
488 &no_agent_auth(),
489 &cache,
490 &endpoints,
491 Duration::from_secs(0),
492 )
493 .await
494 .unwrap();
495 m.assert_async().await;
496 assert_eq!(out.snapshot.plan, "Ultra");
497 assert_eq!(out.snapshot.auto_pct, 98);
498 assert_eq!(out.snapshot.api_pct, 100);
499 assert!(!out.stale);
500 }
501
502 #[tokio::test]
503 async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
504 let (_cache_dir, cache) = cache_fixture();
505 let client = reqwest::Client::new();
506 let endpoints = Endpoints::default();
507 let db_path = std::path::Path::new("/nonexistent/state.vscdb");
508
509 let err = fetch_snapshot(
510 &client,
511 db_path,
512 &no_agent_auth(),
513 &cache,
514 &endpoints,
515 Duration::from_secs(0),
516 )
517 .await
518 .unwrap_err();
519 assert!(matches!(err, AppError::Credentials(_)));
520 }
521
522 #[tokio::test]
523 async fn agent_auth_file_is_used_when_the_ide_db_is_missing() {
524 let mut server = mockito::Server::new_async().await;
525 let token = fake_token("user_123");
526 let m = server
527 .mock("GET", "/api/usage-summary")
528 .match_header(
529 "cookie",
530 format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
531 )
532 .with_status(200)
533 .with_body(sample_json())
534 .create_async()
535 .await;
536
537 let dir = TempDir::new().unwrap();
538 let db_path = dir.path().join("state.vscdb"); let agent_path = dir.path().join("auth.json");
540 std::fs::write(
541 &agent_path,
542 serde_json::json!({"accessToken": token, "refreshToken": "r"}).to_string(),
543 )
544 .unwrap();
545 let (_cache_dir, cache) = cache_fixture();
546 let client = reqwest::Client::new();
547 let endpoints = endpoints_for(&server);
548
549 let out = fetch_snapshot(
550 &client,
551 &db_path,
552 &agent_path,
553 &cache,
554 &endpoints,
555 Duration::from_secs(0),
556 )
557 .await
558 .unwrap();
559 m.assert_async().await;
560 assert_eq!(out.snapshot.plan, "Ultra");
561 assert!(!out.stale);
562 }
563
564 #[tokio::test]
565 async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
566 let mut server = mockito::Server::new_async().await;
567 let token = fake_token("user_123");
568 server
569 .mock("GET", "/api/usage-summary")
570 .with_status(401)
571 .with_body(r#"{"detail":"leaked-looking body"}"#)
572 .create_async()
573 .await;
574
575 let db_dir = TempDir::new().unwrap();
576 let db_path = seed_state_db(&db_dir, &token);
577 let (_cache_dir, cache) = cache_fixture();
578 cache
579 .write_payload(&cached_snapshot(
580 &account_key(&token),
581 "2099-08-04T00:00:00Z",
582 ))
583 .unwrap();
584
585 let client = reqwest::Client::new();
586 let endpoints = endpoints_for(&server);
587 let out = fetch_snapshot(
588 &client,
589 &db_path,
590 &no_agent_auth(),
591 &cache,
592 &endpoints,
593 Duration::from_secs(0),
594 )
595 .await
596 .unwrap();
597 assert!(out.stale);
598 assert_eq!(out.snapshot.auto_pct, 40);
599 let (code, msg) = out.last_error.unwrap();
600 assert_eq!(code, 401);
601 assert_eq!(msg, "Cursor authentication failed");
602 assert!(!msg.contains("leaked-looking"));
603 }
604
605 #[tokio::test]
606 async fn fresh_cache_is_used_after_verifying_the_current_account() {
607 let token = fake_token("user_123");
608 let db_dir = TempDir::new().unwrap();
609 let db_path = seed_state_db(&db_dir, &token);
610 let (_cache_dir, cache) = cache_fixture();
611 cache
612 .write_payload(
613 serde_json::json!({
614 "account": account_key(&token),
615 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
616 "unlimited": false, "on_demand_enabled": true,
617 "on_demand_used_cents": 1785,
618 "on_demand_limit_cents": 35000,
619 "reset_at": "2099-08-04T00:00:00Z",
620 "credits": [],
621 })
622 .to_string()
623 .as_bytes(),
624 )
625 .unwrap();
626
627 let client = reqwest::Client::new();
628 let endpoints = Endpoints::default();
629 let out = fetch_snapshot(
630 &client,
631 &db_path,
632 &no_agent_auth(),
633 &cache,
634 &endpoints,
635 Duration::from_secs(3600),
636 )
637 .await
638 .unwrap();
639 assert_eq!(out.snapshot.auto_pct, 7);
640 assert!(out.snapshot.on_demand_enabled);
641 assert_eq!(out.snapshot.on_demand_used_cents, Some(1785));
642 assert_eq!(out.snapshot.on_demand_limit_cents, Some(35000));
643 assert!(!out.stale);
644 }
645
646 #[tokio::test]
647 async fn fresh_cache_without_a_credits_field_is_refetched() {
648 let token = fake_token("user_123");
649 let db_dir = TempDir::new().unwrap();
650 let db_path = seed_state_db(&db_dir, &token);
651 let (_cache_dir, cache) = cache_fixture();
652 cache
653 .write_payload(
654 serde_json::json!({
655 "account": account_key(&token),
656 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
657 "unlimited": false, "on_demand_enabled": false,
658 "reset_at": "2099-08-04T00:00:00Z",
659 })
660 .to_string()
661 .as_bytes(),
662 )
663 .unwrap();
664
665 let mut server = mockito::Server::new_async().await;
666 let summary = server
667 .mock("GET", "/api/usage-summary")
668 .with_status(200)
669 .with_body(sample_json())
670 .expect(1)
671 .create_async()
672 .await;
673 let grants = server
674 .mock(
675 "POST",
676 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
677 )
678 .with_status(200)
679 .with_body(
680 r#"{"grants":[{"remainingCents":2100,"totalCents":2500,"expiresAtMs":1793577600000,"displayName":"Promo"}]}"#,
681 )
682 .expect(1)
683 .create_async()
684 .await;
685
686 let out = fetch_snapshot(
687 &reqwest::Client::new(),
688 &db_path,
689 &no_agent_auth(),
690 &cache,
691 &endpoints_for(&server),
692 Duration::from_secs(3600),
693 )
694 .await
695 .unwrap();
696 summary.assert_async().await;
697 grants.assert_async().await;
698 assert_eq!(out.snapshot.auto_pct, 98);
699 assert_eq!(out.snapshot.credits.len(), 1);
700 assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
701 assert!(!out.stale);
702 }
703
704 #[tokio::test]
705 async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
706 let old_token = fake_token("old_account");
707 let new_token = fake_token("new_account");
708 let db_dir = TempDir::new().unwrap();
709 let db_path = seed_state_db(&db_dir, &new_token);
710 let (_cache_dir, cache) = cache_fixture();
711 cache
712 .write_payload(&cached_snapshot(
713 &account_key(&old_token),
714 "2099-08-04T00:00:00Z",
715 ))
716 .unwrap();
717
718 let mut server = mockito::Server::new_async().await;
719 let request = server
720 .mock("GET", "/api/usage-summary")
721 .match_header(
722 "cookie",
723 format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
724 )
725 .with_status(200)
726 .with_body(sample_json())
727 .expect(1)
728 .create_async()
729 .await;
730 let endpoints = endpoints_for(&server);
731
732 let out = fetch_snapshot(
733 &reqwest::Client::new(),
734 &db_path,
735 &no_agent_auth(),
736 &cache,
737 &endpoints,
738 Duration::from_secs(3600),
739 )
740 .await
741 .unwrap();
742 request.assert_async().await;
743 assert_eq!(out.snapshot.auto_pct, 98);
744 assert!(!out.stale);
745 }
746
747 #[tokio::test]
748 async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
749 let token = fake_token("user_123");
750 let db_dir = TempDir::new().unwrap();
751 let db_path = seed_state_db(&db_dir, &token);
752 let (_cache_dir, cache) = cache_fixture();
753 cache
754 .write_payload(&cached_snapshot(
755 &account_key(&token),
756 "2026-08-04T00:00:00Z",
757 ))
758 .unwrap();
759
760 let mut server = mockito::Server::new_async().await;
761 server
762 .mock("GET", "/api/usage-summary")
763 .with_status(503)
764 .create_async()
765 .await;
766 let endpoints = endpoints_for(&server);
767 let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
768 .unwrap()
769 .with_timezone(&Utc);
770 let err = fetch_snapshot_at(
771 &reqwest::Client::new(),
772 &db_path,
773 &no_agent_auth(),
774 &cache,
775 &endpoints,
776 Duration::from_secs(0),
777 now,
778 )
779 .await
780 .unwrap_err();
781 assert!(matches!(err, AppError::Http { status: 503, .. }));
782 }
783
784 #[test]
785 fn cached_percentages_are_range_checked_before_narrowing() {
786 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
787 .unwrap()
788 .with_timezone(&Utc);
789 let mut payload: serde_json::Value =
790 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
791 payload["auto_pct"] = serde_json::json!(i64::MAX);
792 let err =
793 parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
794 assert!(matches!(err, AppError::Schema(_)));
795 }
796
797 #[tokio::test]
798 async fn credit_grants_ride_along_and_a_failed_grant_call_keeps_the_bars() {
799 let mut server = mockito::Server::new_async().await;
800 let token = fake_token("user_123");
801 let summary = server
802 .mock("GET", "/api/usage-summary")
803 .with_status(200)
804 .with_body(sample_json())
805 .expect(2)
806 .create_async()
807 .await;
808 let grants = server
809 .mock(
810 "POST",
811 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
812 )
813 .match_header("authorization", format!("Bearer {token}").as_str())
814 .match_header("connect-protocol-version", "1")
815 .match_body("{}")
816 .with_status(200)
817 .with_body(
818 r#"{"grants":[{"remainingCents":"2100","totalCents":2500,"expiresAtMs":"1793577600000","displayName":"Promo"}]}"#,
819 )
820 .expect(1)
821 .create_async()
822 .await;
823
824 let db_dir = TempDir::new().unwrap();
825 let db_path = seed_state_db(&db_dir, &token);
826 let (_cache_dir, cache) = cache_fixture();
827 let client = reqwest::Client::new();
828 let endpoints = endpoints_for(&server);
829
830 let out = fetch_snapshot(
831 &client,
832 &db_path,
833 &no_agent_auth(),
834 &cache,
835 &endpoints,
836 Duration::from_secs(0),
837 )
838 .await
839 .unwrap();
840 grants.assert_async().await;
841 assert!(!out.stale);
842 assert_eq!(out.snapshot.auto_pct, 98);
843 assert_eq!(out.snapshot.credits.len(), 1);
844 assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
845 assert_eq!(out.snapshot.credits[0].total_cents, 2500);
846 assert_eq!(out.snapshot.credits[0].display_name, "Promo");
847 assert!(out.last_error.is_none());
848
849 let down = server
852 .mock(
853 "POST",
854 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
855 )
856 .with_status(503)
857 .with_body("upstream body")
858 .expect(1)
859 .create_async()
860 .await;
861 let again = fetch_snapshot(
862 &client,
863 &db_path,
864 &no_agent_auth(),
865 &cache,
866 &endpoints,
867 Duration::from_secs(0),
868 )
869 .await
870 .unwrap();
871 down.assert_async().await;
872 summary.assert_async().await;
873 assert!(!again.stale);
874 assert_eq!(again.snapshot.auto_pct, 98);
875 assert!(again.snapshot.credits.is_empty());
876 assert!(again.last_error.is_none());
877 }
878
879 #[test]
880 fn cache_round_trips_credit_grants_and_skips_a_bad_one() {
881 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
882 .unwrap()
883 .with_timezone(&Utc);
884 let expires = DateTime::parse_from_rfc3339("2026-11-02T00:00:00Z")
885 .unwrap()
886 .with_timezone(&Utc);
887 let snap = CursorSnapshot {
888 plan: "Pro".into(),
889 auto_pct: 1,
890 api_pct: 2,
891 total_pct: 1,
892 unlimited: false,
893 on_demand_enabled: false,
894 on_demand_used_cents: None,
895 on_demand_limit_cents: None,
896 reset_at: Some(
897 DateTime::parse_from_rfc3339("2026-08-04T00:00:00Z")
898 .unwrap()
899 .with_timezone(&Utc),
900 ),
901 cycle_start: None,
902 credits: vec![CursorCreditGrant {
903 remaining_cents: 2100,
904 total_cents: 2500,
905 expires_at: Some(expires),
906 display_name: "Promo".into(),
907 }],
908 };
909 let parsed = parse_cache_at(
910 &serde_json::to_vec(&snap_to_json(&snap, "account")).unwrap(),
911 "account",
912 now,
913 )
914 .unwrap();
915 assert_eq!(parsed.credits, snap.credits);
916
917 let mut legacy: serde_json::Value =
918 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
919 let without =
920 parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
921 assert!(without.credits.is_empty());
922
923 legacy["credits"] = serde_json::json!([
924 {"remaining_cents": -1, "total_cents": 100},
925 {"remaining_cents": 2100, "total_cents": 2500, "display_name": "Kept"}
926 ]);
927 let mixed = parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
928 assert_eq!(mixed.credits.len(), 1);
929 assert_eq!(mixed.credits[0].display_name, "Kept");
930 assert!(mixed.credits[0].expires_at.is_none());
931 }
932}