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(
318 HTTP_TIMEOUT,
319 client
320 .get(&endpoints.summary)
321 .header(
322 "Cookie",
323 format!("WorkosCursorSessionToken={}", auth.cookie_value),
324 )
325 .header("Origin", BASE_URL)
326 .header("Referer", format!("{BASE_URL}/dashboard"))
327 .header("User-Agent", BROWSER_UA)
328 .header("Accept", "application/json, text/plain, */*")
329 .header("Accept-Language", "en-US,en;q=0.9")
330 .header(
331 "sec-ch-ua",
332 "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
333 )
334 .header("sec-ch-ua-mobile", "?0")
335 .header("sec-ch-ua-platform", "\"macOS\"")
336 .header("sec-fetch-dest", "empty")
337 .header("sec-fetch-mode", "cors")
338 .header("sec-fetch-site", "same-origin")
339 .send(),
340 )
341 .await
342 .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
343
344 let status = resp.status();
345 if !status.is_success() {
346 let body = match status.as_u16() {
347 401 => "Cursor authentication failed".into(),
348 403 => "Cursor request denied: authentication failed or an edge block \
352 (cursor.com gates non-browser clients)"
353 .into(),
354 other => format!("Cursor API returned HTTP {other}"),
355 };
356 return Err(AppError::Http {
357 status: status.as_u16(),
358 body,
359 });
360 }
361
362 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
363 let parsed: UsageSummary = serde_json::from_slice(&bytes)
364 .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
365 let mut snap = types::to_snapshot(parsed)?;
366 snap.credits = fetch_credits(client, endpoints, access_token).await;
367 Ok(snap)
368}
369
370async fn fetch_credits(
373 client: &reqwest::Client,
374 endpoints: &Endpoints,
375 access_token: &str,
376) -> Vec<CursorCreditGrant> {
377 let Ok(Ok(resp)) = tokio::time::timeout(
378 HTTP_TIMEOUT,
379 client
380 .post(&endpoints.credits)
381 .header("Content-Type", "application/json")
382 .header("Authorization", format!("Bearer {access_token}"))
383 .header("Connect-Protocol-Version", "1")
384 .body("{}")
385 .send(),
386 )
387 .await
388 else {
389 return Vec::new();
390 };
391 if !resp.status().is_success() {
392 return Vec::new();
393 }
394 let Ok(bytes) = read_body_capped(resp, MAX_BODY_BYTES).await else {
395 return Vec::new();
396 };
397 types::parse_credit_grants(&bytes)
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use rusqlite::Connection;
404 use tempfile::TempDir;
405
406 fn cache_fixture() -> (TempDir, Cache) {
407 let td = TempDir::new().unwrap();
408 let cache = Cache::at(td.path().join("cursor"));
409 cache.ensure_dir().unwrap();
410 (td, cache)
411 }
412
413 fn fake_token(user_id: &str) -> String {
416 use base64::Engine;
417 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
418 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
419 .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
420 format!("{header}.{payload}.sig")
421 }
422
423 fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
424 let path = dir.path().join("state.vscdb");
425 let conn = Connection::open(&path).unwrap();
426 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
427 .unwrap();
428 conn.execute(
429 "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
430 [token],
431 )
432 .unwrap();
433 path
434 }
435
436 fn account_key(token: &str) -> String {
437 db::session_auth(token).unwrap().account_key
438 }
439
440 fn no_agent_auth() -> std::path::PathBuf {
443 std::path::PathBuf::from("/nonexistent/cursor-agent-auth.json")
444 }
445
446 fn endpoints_for(server: &mockito::Server) -> Endpoints {
447 Endpoints {
448 summary: format!("{}/api/usage-summary", server.url()),
449 credits: format!(
450 "{}/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
451 server.url()
452 ),
453 }
454 }
455
456 fn cached_snapshot(account: &str, reset_at: &str) -> Vec<u8> {
457 serde_json::to_vec(&serde_json::json!({
458 "account": account,
459 "plan": "Ultra",
460 "auto_pct": 40,
461 "api_pct": 10,
462 "total_pct": 30,
463 "unlimited": false,
464 "on_demand_enabled": false,
465 "reset_at": reset_at,
466 }))
467 .unwrap()
468 }
469
470 fn sample_json() -> String {
471 r#"{
472 "billingCycleEnd": "2099-08-04T00:35:51.000Z",
473 "membershipType": "ultra",
474 "isUnlimited": false,
475 "individualUsage": {
476 "plan": { "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
477 "onDemand": { "enabled": false }
478 }
479 }"#
480 .to_string()
481 }
482
483 #[tokio::test]
484 async fn live_fetch_reads_token_from_db_and_sends_the_session_cookie() {
485 let mut server = mockito::Server::new_async().await;
486 let token = fake_token("user_123");
487 let m = server
488 .mock("GET", "/api/usage-summary")
489 .match_header(
490 "cookie",
491 format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
492 )
493 .match_header("user-agent", BROWSER_UA)
497 .match_header("accept", "application/json, text/plain, */*")
498 .match_header("accept-language", "en-US,en;q=0.9")
499 .match_header("sec-ch-ua-mobile", "?0")
500 .match_header("sec-ch-ua-platform", "\"macOS\"")
501 .match_header("sec-fetch-site", "same-origin")
502 .with_status(200)
503 .with_body(sample_json())
504 .create_async()
505 .await;
506
507 let db_dir = TempDir::new().unwrap();
508 let db_path = seed_state_db(&db_dir, &token);
509 let (_cache_dir, cache) = cache_fixture();
510 let client = reqwest::Client::new();
511 let endpoints = endpoints_for(&server);
512
513 let out = fetch_snapshot(
514 &client,
515 &db_path,
516 &no_agent_auth(),
517 &cache,
518 &endpoints,
519 Duration::from_secs(0),
520 )
521 .await
522 .unwrap();
523 m.assert_async().await;
524 assert_eq!(out.snapshot.plan, "Ultra");
525 assert_eq!(out.snapshot.auto_pct, 98);
526 assert_eq!(out.snapshot.api_pct, 100);
527 assert!(!out.stale);
528 }
529
530 #[tokio::test]
531 async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
532 let (_cache_dir, cache) = cache_fixture();
533 let client = reqwest::Client::new();
534 let endpoints = Endpoints::default();
535 let db_path = std::path::Path::new("/nonexistent/state.vscdb");
536
537 let err = fetch_snapshot(
538 &client,
539 db_path,
540 &no_agent_auth(),
541 &cache,
542 &endpoints,
543 Duration::from_secs(0),
544 )
545 .await
546 .unwrap_err();
547 assert!(matches!(err, AppError::Credentials(_)));
548 }
549
550 #[tokio::test]
551 async fn agent_auth_file_is_used_when_the_ide_db_is_missing() {
552 let mut server = mockito::Server::new_async().await;
553 let token = fake_token("user_123");
554 let m = server
555 .mock("GET", "/api/usage-summary")
556 .match_header(
557 "cookie",
558 format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
559 )
560 .with_status(200)
561 .with_body(sample_json())
562 .create_async()
563 .await;
564
565 let dir = TempDir::new().unwrap();
566 let db_path = dir.path().join("state.vscdb"); let agent_path = dir.path().join("auth.json");
568 std::fs::write(
569 &agent_path,
570 serde_json::json!({"accessToken": token, "refreshToken": "r"}).to_string(),
571 )
572 .unwrap();
573 let (_cache_dir, cache) = cache_fixture();
574 let client = reqwest::Client::new();
575 let endpoints = endpoints_for(&server);
576
577 let out = fetch_snapshot(
578 &client,
579 &db_path,
580 &agent_path,
581 &cache,
582 &endpoints,
583 Duration::from_secs(0),
584 )
585 .await
586 .unwrap();
587 m.assert_async().await;
588 assert_eq!(out.snapshot.plan, "Ultra");
589 assert!(!out.stale);
590 }
591
592 #[tokio::test]
593 async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
594 let mut server = mockito::Server::new_async().await;
595 let token = fake_token("user_123");
596 server
597 .mock("GET", "/api/usage-summary")
598 .with_status(401)
599 .with_body(r#"{"detail":"leaked-looking body"}"#)
600 .create_async()
601 .await;
602
603 let db_dir = TempDir::new().unwrap();
604 let db_path = seed_state_db(&db_dir, &token);
605 let (_cache_dir, cache) = cache_fixture();
606 cache
607 .write_payload(&cached_snapshot(
608 &account_key(&token),
609 "2099-08-04T00:00:00Z",
610 ))
611 .unwrap();
612
613 let client = reqwest::Client::new();
614 let endpoints = endpoints_for(&server);
615 let out = fetch_snapshot(
616 &client,
617 &db_path,
618 &no_agent_auth(),
619 &cache,
620 &endpoints,
621 Duration::from_secs(0),
622 )
623 .await
624 .unwrap();
625 assert!(out.stale);
626 assert_eq!(out.snapshot.auto_pct, 40);
627 let (code, msg) = out.last_error.unwrap();
628 assert_eq!(code, 401);
629 assert_eq!(msg, "Cursor authentication failed");
630 assert!(!msg.contains("leaked-looking"));
631 }
632
633 #[tokio::test]
634 async fn fresh_cache_is_used_after_verifying_the_current_account() {
635 let token = fake_token("user_123");
636 let db_dir = TempDir::new().unwrap();
637 let db_path = seed_state_db(&db_dir, &token);
638 let (_cache_dir, cache) = cache_fixture();
639 cache
640 .write_payload(
641 serde_json::json!({
642 "account": account_key(&token),
643 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
644 "unlimited": false, "on_demand_enabled": true,
645 "on_demand_used_cents": 1785,
646 "on_demand_limit_cents": 35000,
647 "reset_at": "2099-08-04T00:00:00Z",
648 "credits": [],
649 })
650 .to_string()
651 .as_bytes(),
652 )
653 .unwrap();
654
655 let client = reqwest::Client::new();
656 let endpoints = Endpoints::default();
657 let out = fetch_snapshot(
658 &client,
659 &db_path,
660 &no_agent_auth(),
661 &cache,
662 &endpoints,
663 Duration::from_secs(3600),
664 )
665 .await
666 .unwrap();
667 assert_eq!(out.snapshot.auto_pct, 7);
668 assert!(out.snapshot.on_demand_enabled);
669 assert_eq!(out.snapshot.on_demand_used_cents, Some(1785));
670 assert_eq!(out.snapshot.on_demand_limit_cents, Some(35000));
671 assert!(!out.stale);
672 }
673
674 #[tokio::test]
675 async fn fresh_cache_without_a_credits_field_is_refetched() {
676 let token = fake_token("user_123");
677 let db_dir = TempDir::new().unwrap();
678 let db_path = seed_state_db(&db_dir, &token);
679 let (_cache_dir, cache) = cache_fixture();
680 cache
681 .write_payload(
682 serde_json::json!({
683 "account": account_key(&token),
684 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
685 "unlimited": false, "on_demand_enabled": false,
686 "reset_at": "2099-08-04T00:00:00Z",
687 })
688 .to_string()
689 .as_bytes(),
690 )
691 .unwrap();
692
693 let mut server = mockito::Server::new_async().await;
694 let summary = server
695 .mock("GET", "/api/usage-summary")
696 .with_status(200)
697 .with_body(sample_json())
698 .expect(1)
699 .create_async()
700 .await;
701 let grants = server
702 .mock(
703 "POST",
704 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
705 )
706 .with_status(200)
707 .with_body(
708 r#"{"grants":[{"remainingCents":2100,"totalCents":2500,"expiresAtMs":1793577600000,"displayName":"Promo"}]}"#,
709 )
710 .expect(1)
711 .create_async()
712 .await;
713
714 let out = fetch_snapshot(
715 &reqwest::Client::new(),
716 &db_path,
717 &no_agent_auth(),
718 &cache,
719 &endpoints_for(&server),
720 Duration::from_secs(3600),
721 )
722 .await
723 .unwrap();
724 summary.assert_async().await;
725 grants.assert_async().await;
726 assert_eq!(out.snapshot.auto_pct, 98);
727 assert_eq!(out.snapshot.credits.len(), 1);
728 assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
729 assert!(!out.stale);
730 }
731
732 #[tokio::test]
733 async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
734 let old_token = fake_token("old_account");
735 let new_token = fake_token("new_account");
736 let db_dir = TempDir::new().unwrap();
737 let db_path = seed_state_db(&db_dir, &new_token);
738 let (_cache_dir, cache) = cache_fixture();
739 cache
740 .write_payload(&cached_snapshot(
741 &account_key(&old_token),
742 "2099-08-04T00:00:00Z",
743 ))
744 .unwrap();
745
746 let mut server = mockito::Server::new_async().await;
747 let request = server
748 .mock("GET", "/api/usage-summary")
749 .match_header(
750 "cookie",
751 format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
752 )
753 .with_status(200)
754 .with_body(sample_json())
755 .expect(1)
756 .create_async()
757 .await;
758 let endpoints = endpoints_for(&server);
759
760 let out = fetch_snapshot(
761 &reqwest::Client::new(),
762 &db_path,
763 &no_agent_auth(),
764 &cache,
765 &endpoints,
766 Duration::from_secs(3600),
767 )
768 .await
769 .unwrap();
770 request.assert_async().await;
771 assert_eq!(out.snapshot.auto_pct, 98);
772 assert!(!out.stale);
773 }
774
775 #[tokio::test]
776 async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
777 let token = fake_token("user_123");
778 let db_dir = TempDir::new().unwrap();
779 let db_path = seed_state_db(&db_dir, &token);
780 let (_cache_dir, cache) = cache_fixture();
781 cache
782 .write_payload(&cached_snapshot(
783 &account_key(&token),
784 "2026-08-04T00:00:00Z",
785 ))
786 .unwrap();
787
788 let mut server = mockito::Server::new_async().await;
789 server
790 .mock("GET", "/api/usage-summary")
791 .with_status(503)
792 .create_async()
793 .await;
794 let endpoints = endpoints_for(&server);
795 let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
796 .unwrap()
797 .with_timezone(&Utc);
798 let err = fetch_snapshot_at(
799 &reqwest::Client::new(),
800 &db_path,
801 &no_agent_auth(),
802 &cache,
803 &endpoints,
804 Duration::from_secs(0),
805 now,
806 )
807 .await
808 .unwrap_err();
809 assert!(matches!(err, AppError::Http { status: 503, .. }));
810 }
811
812 #[test]
813 fn cached_percentages_are_range_checked_before_narrowing() {
814 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
815 .unwrap()
816 .with_timezone(&Utc);
817 let mut payload: serde_json::Value =
818 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
819 payload["auto_pct"] = serde_json::json!(i64::MAX);
820 let err =
821 parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
822 assert!(matches!(err, AppError::Schema(_)));
823 }
824
825 #[tokio::test]
826 async fn credit_grants_ride_along_and_a_failed_grant_call_keeps_the_bars() {
827 let mut server = mockito::Server::new_async().await;
828 let token = fake_token("user_123");
829 let summary = server
830 .mock("GET", "/api/usage-summary")
831 .with_status(200)
832 .with_body(sample_json())
833 .expect(2)
834 .create_async()
835 .await;
836 let grants = server
837 .mock(
838 "POST",
839 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
840 )
841 .match_header("authorization", format!("Bearer {token}").as_str())
842 .match_header("connect-protocol-version", "1")
843 .match_body("{}")
844 .with_status(200)
845 .with_body(
846 r#"{"grants":[{"remainingCents":"2100","totalCents":2500,"expiresAtMs":"1793577600000","displayName":"Promo"}]}"#,
847 )
848 .expect(1)
849 .create_async()
850 .await;
851
852 let db_dir = TempDir::new().unwrap();
853 let db_path = seed_state_db(&db_dir, &token);
854 let (_cache_dir, cache) = cache_fixture();
855 let client = reqwest::Client::new();
856 let endpoints = endpoints_for(&server);
857
858 let out = fetch_snapshot(
859 &client,
860 &db_path,
861 &no_agent_auth(),
862 &cache,
863 &endpoints,
864 Duration::from_secs(0),
865 )
866 .await
867 .unwrap();
868 grants.assert_async().await;
869 assert!(!out.stale);
870 assert_eq!(out.snapshot.auto_pct, 98);
871 assert_eq!(out.snapshot.credits.len(), 1);
872 assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
873 assert_eq!(out.snapshot.credits[0].total_cents, 2500);
874 assert_eq!(out.snapshot.credits[0].display_name, "Promo");
875 assert!(out.last_error.is_none());
876
877 let down = server
880 .mock(
881 "POST",
882 "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
883 )
884 .with_status(503)
885 .with_body("upstream body")
886 .expect(1)
887 .create_async()
888 .await;
889 let again = fetch_snapshot(
890 &client,
891 &db_path,
892 &no_agent_auth(),
893 &cache,
894 &endpoints,
895 Duration::from_secs(0),
896 )
897 .await
898 .unwrap();
899 down.assert_async().await;
900 summary.assert_async().await;
901 assert!(!again.stale);
902 assert_eq!(again.snapshot.auto_pct, 98);
903 assert!(again.snapshot.credits.is_empty());
904 assert!(again.last_error.is_none());
905 }
906
907 #[test]
908 fn cache_round_trips_credit_grants_and_skips_a_bad_one() {
909 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
910 .unwrap()
911 .with_timezone(&Utc);
912 let expires = DateTime::parse_from_rfc3339("2026-11-02T00:00:00Z")
913 .unwrap()
914 .with_timezone(&Utc);
915 let snap = CursorSnapshot {
916 plan: "Pro".into(),
917 auto_pct: 1,
918 api_pct: 2,
919 total_pct: 1,
920 unlimited: false,
921 on_demand_enabled: false,
922 on_demand_used_cents: None,
923 on_demand_limit_cents: None,
924 reset_at: Some(
925 DateTime::parse_from_rfc3339("2026-08-04T00:00:00Z")
926 .unwrap()
927 .with_timezone(&Utc),
928 ),
929 cycle_start: None,
930 credits: vec![CursorCreditGrant {
931 remaining_cents: 2100,
932 total_cents: 2500,
933 expires_at: Some(expires),
934 display_name: "Promo".into(),
935 }],
936 };
937 let parsed = parse_cache_at(
938 &serde_json::to_vec(&snap_to_json(&snap, "account")).unwrap(),
939 "account",
940 now,
941 )
942 .unwrap();
943 assert_eq!(parsed.credits, snap.credits);
944
945 let mut legacy: serde_json::Value =
946 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
947 let without =
948 parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
949 assert!(without.credits.is_empty());
950
951 legacy["credits"] = serde_json::json!([
952 {"remaining_cents": -1, "total_cents": 100},
953 {"remaining_cents": 2100, "total_cents": 2500, "display_name": "Kept"}
954 ]);
955 let mixed = parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
956 assert_eq!(mixed.credits.len(), 1);
957 assert_eq!(mixed.credits[0].display_name, "Kept");
958 assert!(mixed.credits[0].expires_at.is_none());
959 }
960}