Skip to main content

ai_usagebar/cursor/
fetch.rs

1//! Fetch Cursor's included-usage summary from `GET /api/usage-summary`,
2//! authenticated with the session token read out of the local `state.vscdb`
3//! (see `db.rs`). Cache/stale/error-fallback shape mirrors `kimi::fetch`.
4//!
5//! Credit grants are a second call, `POST GetClientVisibleCreditGrants` on
6//! `api2.cursor.sh`, with the same access token as a Bearer credential. That
7//! call is best-effort: a timeout, a non-2xx, or a body that is not a grant
8//! list leaves `credits` empty and the usage bars up.
9
10use 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";
24/// Connect-RPC the spending page's Credits card is drawn from. Same access
25/// token as the usage-summary cookie, sent as a Bearer credential.
26pub 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);
30/// The dashboard endpoint gates on browser-looking headers; a plain
31/// `reqwest` request with only the cookie is rejected by its CORS check.
32const 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
50/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
51/// specialised to its snapshot.
52pub type FetchOutcome = crate::outcome::Outcome<CursorSnapshot>;
53
54/// Cache-aware fetch. `db_path` is Cursor's `state.vscdb` — the caller resolves
55/// `[cursor] db_path` (config override) vs [`db::default_db_path`], the same
56/// override pattern as `openai.codex_auth_path`. `agent_auth_path` is the
57/// headless `cursor-agent` CLI's own `auth.json`, tried when `db_path` is
58/// missing — see `db::resolve_access_token`, which on macOS also tries the
59/// CLI's Keychain items.
60pub 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
80/// Clock seam for cache rollover tests. Cursor's payload describes one billing
81/// cycle, so serving it after `reset_at` would knowingly show the prior cycle.
82async 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    // Resolve the local identity before accepting a cache hit. Cursor can switch
95    // accounts in-place in this database; returning the cache first would show
96    // the previous account's private usage until the TTL elapsed.
97    let token = db::resolve_access_token(db_path, agent_auth_path)?;
98    let auth = db::session_auth(&token)?;
99
100    // A payload written before credit grants existed has no `credits` key.
101    // Serving it fresh would hide a balance the spending page is showing
102    // until the TTL elapsed, so that cache is not a hit.
103    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
150/// Never surface upstream bodies for auth failures: the request carried a
151/// session cookie derived from a signed-in token, and 401/403 bodies from a
152/// scraped web endpoint are not guaranteed not to echo it back.
153fn 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
232/// A missing `credits` key is a cache written before grants existed. A bad
233/// entry is skipped so one corrupt grant cannot throw away the usage bars.
234/// `credits` must be present, even as an empty list. Absence means the
235/// payload predates the field and is not a complete snapshot.
236fn 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    // usage-summary keys off the session cookie alone (no `?user=` param); the
313    // browser-ish headers get past its CORS gate.
314    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
351/// Best-effort. Anything other than a 2xx grant list — including auth failure
352/// on this call alone — is an empty balance, not an error on the usage bars.
353async 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    /// A minimal, unsigned JWT with `sub: "auth0|<user_id>"` — signature
395    /// verification is never performed (see `db::parse_jwt_claims`).
396    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    /// A path that never exists, for tests that only care about the IDE
422    /// `db_path` and want the agent fallback to stay out of the way.
423    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"); // deliberately never seeded
539        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        // Registered after the success mock so it is the one the refetch hits.
850        // A grant-call failure must not stale the bars or surface the body.
851        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}