Skip to main content

ai_usagebar/kimi/
fetch.rs

1//! Fetch Kimi usage from `/coding/v1/usages`.
2//!
3//! One endpoint, two credentials: an **API key**, or the **Kimi Code CLI's
4//! OAuth session** — the credential a subscriber already has locally, with no
5//! key to create or paste. See [`Auth`] and `oauth.rs`.
6
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11
12use crate::cache::{Cache, acquire_lock_async};
13use crate::error::{AppError, Result};
14use crate::usage::KimiSnapshot;
15
16use super::oauth::{self, Region};
17use super::types::{UsagesResponse, UserInfoResponse, humanize_membership_level};
18
19pub const BASE_URL: &str = "https://api.kimi.com";
20const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
21const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
22const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
23/// Stable marker stored alongside code 0 for a successful HTTP response whose
24/// payload no longer matches Kimi's undocumented usage schema.
25pub const SCHEMA_DRIFT_MESSAGE: &str = "Kimi API schema drift";
26
27#[derive(Debug, Clone)]
28pub struct Endpoints {
29    pub usages: String,
30    /// Profile endpoint, read solely for the subscription's own tier name.
31    pub me: String,
32    /// OAuth token endpoint for the same deployment. Unused by the API-key
33    /// path, which never refreshes anything.
34    pub token: String,
35}
36
37impl Default for Endpoints {
38    fn default() -> Self {
39        Self::for_region(Region::MainlandCn)
40    }
41}
42
43impl Endpoints {
44    pub fn for_region(region: Region) -> Self {
45        Self {
46            usages: format!("{}/usages", region.api_base()),
47            me: format!("{}/me", region.api_base()),
48            token: oauth::token_endpoint(region.oauth_host()),
49        }
50    }
51}
52
53/// Where the bearer for `/coding/v1/usages` comes from.
54#[derive(Debug, Clone)]
55pub enum Auth {
56    /// A platform API key (`KIMI_API_KEY` or `[kimi] api_key`).
57    ApiKey(String),
58    /// The Kimi Code CLI's own OAuth session — a subscription login.
59    KimiCode(KimiCodeAuth),
60}
61
62/// The two paths inside a kimi-code home this vendor touches: the credential
63/// file it reads and rewrites, and the lock target that serializes a refresh
64/// against the CLI's own (see `lock.rs`).
65#[derive(Debug, Clone)]
66pub struct KimiCodeAuth {
67    pub credentials_path: PathBuf,
68    pub lock_target: PathBuf,
69}
70
71impl KimiCodeAuth {
72    pub fn in_home(home: &Path) -> Self {
73        Self {
74            credentials_path: oauth::credentials_path_in(home),
75            lock_target: oauth::lock_target_in(home),
76        }
77    }
78
79    /// A credential file relocated by config (`[kimi] credentials_path`) still
80    /// belongs to a kimi-code home; the lock target is derived from that home
81    /// so both clients agree on which file the lock protects.
82    pub fn with_credentials_path(home: &Path, credentials_path: PathBuf) -> Self {
83        Self {
84            credentials_path,
85            lock_target: oauth::lock_target_in(home),
86        }
87    }
88}
89
90/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
91/// specialised to its snapshot.
92pub type FetchOutcome = crate::outcome::Outcome<KimiSnapshot>;
93
94/// API-key fetch. Kept as-is for existing callers; the OAuth path goes
95/// through [`fetch_snapshot_with_auth`].
96pub async fn fetch_snapshot(
97    client: &reqwest::Client,
98    api_key: &str,
99    cache: &Cache,
100    endpoints: &Endpoints,
101    cache_ttl: Duration,
102) -> Result<FetchOutcome> {
103    fetch_snapshot_with_auth(
104        client,
105        &Auth::ApiKey(api_key.to_string()),
106        cache,
107        endpoints,
108        cache_ttl,
109    )
110    .await
111}
112
113pub async fn fetch_snapshot_with_auth(
114    client: &reqwest::Client,
115    auth: &Auth,
116    cache: &Cache,
117    endpoints: &Endpoints,
118    cache_ttl: Duration,
119) -> Result<FetchOutcome> {
120    fetch_snapshot_at(client, auth, cache, endpoints, cache_ttl, Utc::now()).await
121}
122
123/// Clock seam for the OAuth expiry decision, mirroring
124/// `kiro::fetch::fetch_snapshot_at`.
125async fn fetch_snapshot_at(
126    client: &reqwest::Client,
127    auth: &Auth,
128    cache: &Cache,
129    endpoints: &Endpoints,
130    cache_ttl: Duration,
131    now: DateTime<Utc>,
132) -> Result<FetchOutcome> {
133    cache.ensure_dir()?;
134    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
135
136    if let Some(bytes) = cache.fresh_payload(cache_ttl)? {
137        // Releases before the profile lookup cached `/usages`' internal enum
138        // verbatim. Do not let a still-fresh legacy entry postpone `/me` until
139        // the normal TTL expires: refresh it once and replace it with Kimi's
140        // own tier name. If the network is down, the fallback path below still
141        // serves the quota after humanizing the enum.
142        if !cache_has_legacy_plan(&bytes)
143            && let Ok(outcome) = reuse_cache(bytes, cache, false)
144        {
145            return Ok(outcome);
146        }
147    }
148    // Corrupt fresh cache: fall through to live fetch rather than return a
149    // fabricated zero snapshot.
150
151    match fetch_live(client, endpoints, auth, now).await {
152        Ok(snap) => {
153            let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
154            cache.write_payload(&bytes)?;
155            Ok(crate::outcome::Outcome::fresh(snap))
156        }
157        Err(e) if e.is_transient() => fallback_silent(cache, e),
158        Err(e) => {
159            cache.mark_stale();
160            if let Some((code, msg)) = error_to_pair(&e) {
161                cache.write_last_error(code, &msg);
162            }
163            fallback_with_error(cache, e)
164        }
165    }
166}
167
168fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
169    crate::outcome::fallback(cache, None, original, parse_cache)
170}
171
172fn fallback_with_error(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
173    let last_error = error_to_pair(&original);
174    crate::outcome::fallback(cache, last_error, original, parse_cache)
175}
176
177fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
178    match e {
179        AppError::Http { status, body } => Some((*status, body.clone())),
180        // A 2xx response with an unknown shape is not an HTTP 422 response.
181        AppError::Schema(_) => Some((0, SCHEMA_DRIFT_MESSAGE.into())),
182        e => Some((0, e.to_string())),
183    }
184}
185
186fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
187    let snap = parse_cache(&bytes)?;
188    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
189}
190
191fn parse_cache(bytes: &[u8]) -> Result<KimiSnapshot> {
192    let v: serde_json::Value = serde_json::from_slice(bytes)?;
193    let weekly_limit = parse_cache_u64(&v["weekly_limit"], "weekly_limit")?;
194    let weekly_used = parse_cache_u64(&v["weekly_used"], "weekly_used")?;
195    let weekly_remaining = parse_cache_u64(&v["weekly_remaining"], "weekly_remaining")?;
196    // Caches written before the monthly shape have no `has_weekly` key; they
197    // all carried weekly counters, so absence means the legacy shape.
198    let has_weekly = match &v["has_weekly"] {
199        serde_json::Value::Null => true,
200        serde_json::Value::Bool(b) => *b,
201        _ => return Err(AppError::Schema("kimi cache: invalid has_weekly".into())),
202    };
203    if !has_weekly && (weekly_limit > 0 || weekly_used > 0 || weekly_remaining > 0) {
204        // A combination the fetch path can never produce: the newer shape has
205        // no weekly counters, so nonzero ones here are a corrupt cache.
206        return Err(AppError::Schema(
207            "kimi cache: weekly counters on a monthly-shape snapshot".into(),
208        ));
209    }
210    let monthly_pct = parse_cache_monthly_pct(&v["monthly_pct"])?;
211    Ok(KimiSnapshot {
212        plan: v["plan"].as_str().map(|plan| {
213            if plan.starts_with("LEVEL_") {
214                humanize_membership_level(plan)
215            } else {
216                plan.to_string()
217            }
218        }),
219        weekly_limit,
220        weekly_used,
221        weekly_remaining,
222        weekly_reset_at: parse_cache_datetime(&v["weekly_reset_at"])?,
223        has_weekly,
224        monthly_pct,
225        monthly_reset_at: parse_cache_datetime(&v["monthly_reset_at"])?,
226        window_limit: parse_cache_u64(&v["window_limit"], "window_limit")?,
227        window_used: parse_cache_u64(&v["window_used"], "window_used")?,
228        window_remaining: parse_cache_u64(&v["window_remaining"], "window_remaining")?,
229        window_reset_at: parse_cache_datetime(&v["window_reset_at"])?,
230    })
231}
232
233fn cache_has_legacy_plan(bytes: &[u8]) -> bool {
234    serde_json::from_slice::<serde_json::Value>(bytes)
235        .ok()
236        .and_then(|value| value["plan"].as_str().map(str::to_owned))
237        .is_some_and(|plan| plan.starts_with("LEVEL_"))
238}
239
240fn parse_cache_u64(v: &serde_json::Value, name: &str) -> Result<u64> {
241    v.as_u64()
242        .ok_or_else(|| AppError::Schema(format!("kimi cache: invalid {name}")))
243}
244
245fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
246    match v {
247        serde_json::Value::Null => Ok(None),
248        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
249            .map(|dt| Some(dt.into()))
250            .map_err(|e| AppError::Schema(format!("kimi cache: invalid reset timestamp: {e}"))),
251        _ => Err(AppError::Schema(
252            "kimi cache: invalid reset timestamp".into(),
253        )),
254    }
255}
256
257/// A percentage the fetch path already clamped to 0..=100; anything else in
258/// the cache is corruption, not a quota.
259fn parse_cache_monthly_pct(v: &serde_json::Value) -> Result<Option<i32>> {
260    match v {
261        serde_json::Value::Null => Ok(None),
262        serde_json::Value::Number(n) => {
263            let pct = n
264                .as_i64()
265                .ok_or_else(|| AppError::Schema("kimi cache: invalid monthly_pct".into()))?;
266            if !(0..=100).contains(&pct) {
267                return Err(AppError::Schema(
268                    "kimi cache: monthly_pct out of range".into(),
269                ));
270            }
271            Ok(Some(pct as i32))
272        }
273        _ => Err(AppError::Schema("kimi cache: invalid monthly_pct".into())),
274    }
275}
276
277fn snap_to_json(snap: &KimiSnapshot) -> serde_json::Value {
278    serde_json::json!({
279        "plan": snap.plan,
280        "weekly_limit": snap.weekly_limit,
281        "weekly_used": snap.weekly_used,
282        "weekly_remaining": snap.weekly_remaining,
283        "weekly_reset_at": snap.weekly_reset_at.map(|dt| dt.to_rfc3339()),
284        "has_weekly": snap.has_weekly,
285        "monthly_pct": snap.monthly_pct,
286        "monthly_reset_at": snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
287        "window_limit": snap.window_limit,
288        "window_used": snap.window_used,
289        "window_remaining": snap.window_remaining,
290        "window_reset_at": snap.window_reset_at.map(|dt| dt.to_rfc3339()),
291    })
292}
293
294/// Resolve the bearer for this fetch. The API key is already one; a Kimi Code
295/// login may first need a refresh, which rotates the CLI's stored token pair
296/// The access token to send, or `None` when one has to be fetched.
297///
298/// A future `expires_at` normally means the token is good. A *blank*
299/// `access_token` next to it is an internally inconsistent file — the CLI
300/// never writes one — but trusting the timestamp there sends
301/// `Authorization: Bearer ` and turns a logged-out state into an opaque 401
302/// instead of the re-login the user actually needs. So a blank token counts as
303/// expired and the refresh token gets its chance; `read_from` has already
304/// refused a blank refresh token, so the fallthrough ends in a credentials
305/// error either way. Mirrors the Claude (#370) and Codex (#373) guards (#382).
306fn usable_access_token(creds: &oauth::Credentials, now: DateTime<Utc>) -> Option<String> {
307    if oauth::needs_refresh(creds.expires_at, now.timestamp()) {
308        return None;
309    }
310    if creds.access_token.trim().is_empty() {
311        return None;
312    }
313    Some(creds.access_token.clone())
314}
315
316/// and is therefore serialized against the CLI itself.
317async fn bearer_token(
318    client: &reqwest::Client,
319    endpoints: &Endpoints,
320    auth: &Auth,
321    now: DateTime<Utc>,
322) -> Result<String> {
323    let kimi_code = match auth {
324        Auth::ApiKey(key) => return Ok(key.clone()),
325        Auth::KimiCode(kimi_code) => kimi_code,
326    };
327
328    let creds = oauth::read_from(&kimi_code.credentials_path)?;
329    if let Some(token) = usable_access_token(&creds, now) {
330        return Ok(token);
331    }
332
333    let _lock = super::lock::acquire(&kimi_code.lock_target).await?;
334    // Re-read under the lock: the CLI (or another ai-usagebar process) may
335    // have refreshed while we waited, and reusing our pre-lock copy would burn
336    // an already-rotated refresh token.
337    let creds = oauth::read_from(&kimi_code.credentials_path)?;
338    if let Some(token) = usable_access_token(&creds, now) {
339        return Ok(token);
340    }
341
342    let refreshed = tokio::time::timeout(
343        REFRESH_TIMEOUT,
344        oauth::refresh(
345            client,
346            &endpoints.token,
347            oauth::CLIENT_ID,
348            &creds.refresh_token,
349        ),
350    )
351    .await
352    .map_err(|_| AppError::Transport(format!("kimi token refresh timeout: {}", endpoints.token)))?
353    .map_err(|e| match e {
354        AppError::Transport(msg) => AppError::Transport(msg),
355        e => AppError::Credentials(format!(
356            "Kimi Code CLI token refresh failed ({e}). Run `kimi` and log in again."
357        )),
358    })?;
359
360    let next = oauth::apply_refresh(&creds, refreshed, now.timestamp());
361    oauth::write_to(&kimi_code.credentials_path, &next).map_err(|e| {
362        // The rotation already happened upstream, so a failed write-back means
363        // the CLI is now holding a dead refresh token: say so plainly instead
364        // of leaving the user to discover it at their next `kimi` run.
365        AppError::Credentials(format!(
366            "the refreshed Kimi Code CLI credentials could not be saved ({e}); run `kimi` and log in again"
367        ))
368    })?;
369    Ok(next.access_token)
370}
371
372/// Ask `/coding/v1/me` for the subscription's own tier name ("Allegretto"),
373/// which is the only place the vendor spells its plans the way its pricing
374/// page does — `/usages` only carries the `LEVEL_*` wire enum.
375///
376/// Best-effort by construction: every failure returns `None` and leaves the
377/// humanized enum in place. A missing plan label must never cost the user
378/// their quota numbers, and this endpoint is documented (by kimi-code's own
379/// error text) to 404 for accounts without a coding profile.
380async fn plan_label(client: &reqwest::Client, url: &str, token: &str) -> Option<String> {
381    let resp = tokio::time::timeout(
382        HTTP_TIMEOUT,
383        client
384            .get(url)
385            .header("Authorization", format!("Bearer {token}"))
386            .header("Accept", "application/json")
387            .send(),
388    )
389    .await
390    .ok()?
391    .ok()?;
392    if !resp.status().is_success() {
393        return None;
394    }
395    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
396        .await
397        .ok()?;
398    serde_json::from_slice::<UserInfoResponse>(&bytes)
399        .ok()?
400        .plan_label()
401}
402
403async fn fetch_live(
404    client: &reqwest::Client,
405    endpoints: &Endpoints,
406    auth: &Auth,
407    now: DateTime<Utc>,
408) -> Result<KimiSnapshot> {
409    let url = &endpoints.usages;
410    let token = bearer_token(client, endpoints, auth, now).await?;
411    // Concurrent, not sequential: the plan label is a second request against
412    // the same deployment, and a widget tick should cost one round-trip's
413    // latency, not two.
414    let (usages, label) = tokio::join!(
415        tokio::time::timeout(
416            HTTP_TIMEOUT,
417            client
418                .get(url)
419                .header("Authorization", format!("Bearer {token}"))
420                .header("Accept", "application/json")
421                .send(),
422        ),
423        plan_label(client, &endpoints.me, &token),
424    );
425    let resp = usages.map_err(|_| AppError::Transport(format!("kimi timeout: {url}")))??;
426
427    let status = resp.status();
428
429    if !status.is_success() {
430        // Never surface upstream/proxy bodies: they can contain credentials or
431        // arbitrary markup. Keep the cached diagnostic useful but generic.
432        let body = if matches!(status.as_u16(), 401 | 403) {
433            "Kimi authentication failed".into()
434        } else {
435            format!("Kimi API returned HTTP {}", status.as_u16())
436        };
437        return Err(AppError::Http {
438            status: status.as_u16(),
439            body,
440        });
441    }
442
443    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
444    let r: UsagesResponse = serde_json::from_slice(&bytes)
445        .map_err(|e| AppError::Schema(format!("kimi usages response: {e}")))?;
446    let mut snap = r.into_snapshot()?;
447    if let Some(label) = label {
448        snap.plan = Some(label);
449    }
450    Ok(snap)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    /// A blank access token beside a future `expires_at` is a file the CLI
458    /// never writes, but trusting the timestamp sent `Authorization: Bearer `
459    /// and turned a logged-out state into an opaque 401 (#382).
460    #[test]
461    fn a_blank_access_token_is_refreshed_rather_than_sent() {
462        let now = chrono::Utc::now();
463        let creds = |access: &str, expires_at: i64| crate::kimi::oauth::Credentials {
464            access_token: access.to_string(),
465            refresh_token: "rt".to_string(),
466            expires_at,
467            expires_in: 900,
468            scope: String::new(),
469            token_type: "Bearer".to_string(),
470            extra: serde_json::Map::new(),
471        };
472        let live = now.timestamp() + 3_600;
473        let expired = now.timestamp() - 3_600;
474
475        // The normal case is untouched: a live token is returned as-is.
476        assert_eq!(
477            usable_access_token(&creds("at", live), now).as_deref(),
478            Some("at")
479        );
480        // Blank, or whitespace-only, with a future expiry: refresh instead.
481        assert_eq!(usable_access_token(&creds("", live), now), None);
482        assert_eq!(usable_access_token(&creds("   ", live), now), None);
483        // And an expired token still refreshes, blank or not.
484        assert_eq!(usable_access_token(&creds("at", expired), now), None);
485        assert_eq!(usable_access_token(&creds("", expired), now), None);
486    }
487    use tempfile::TempDir;
488
489    fn cache_fixture() -> (TempDir, Cache) {
490        let td = TempDir::new().unwrap();
491        let cache = Cache::at(td.path().join("kimi"));
492        cache.ensure_dir().unwrap();
493        (td, cache)
494    }
495
496    /// Both endpoints point at the same mock server, so an OAuth test can
497    /// serve `/api/oauth/token` and `/coding/v1/usages` from one mockito.
498    fn test_endpoints(base: &str) -> Endpoints {
499        Endpoints {
500            usages: format!("{base}/coding/v1/usages"),
501            me: format!("{base}/coding/v1/me"),
502            token: format!("{base}/api/oauth/token"),
503        }
504    }
505
506    fn sample_json() -> &'static str {
507        r#"{
508            "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
509            "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
510            "limits": [
511                {
512                    "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
513                    "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
514                }
515            ]
516        }"#
517    }
518
519    fn sample_seed() -> serde_json::Value {
520        serde_json::json!({
521            "plan": "LEVEL_INTERMEDIATE",
522            "weekly_limit": 100,
523            "weekly_used": 30,
524            "weekly_remaining": 70,
525            "weekly_reset_at": "2026-02-11T17:32:50.757941Z",
526            "window_limit": 100,
527            "window_used": 20,
528            "window_remaining": 80,
529            "window_reset_at": "2026-02-07T12:32:50.757941Z"
530        })
531    }
532
533    #[tokio::test]
534    async fn live_200_returns_snapshot_and_sends_headers() {
535        let mut server = mockito::Server::new_async().await;
536        let m = server
537            .mock("GET", "/coding/v1/usages")
538            .with_status(200)
539            .with_body(sample_json())
540            .match_header("authorization", "Bearer sk-test")
541            .match_header("accept", "application/json")
542            .create_async()
543            .await;
544
545        let (_td, cache) = cache_fixture();
546        let client = reqwest::Client::new();
547        let endpoints = test_endpoints(&server.url());
548        let out = fetch_snapshot(
549            &client,
550            "sk-test",
551            &cache,
552            &endpoints,
553            Duration::from_secs(0),
554        )
555        .await
556        .unwrap();
557        m.assert_async().await;
558        // No /me mock on this server, so the humanized wire enum stands.
559        assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
560        assert_eq!(out.snapshot.weekly_limit, 100);
561        assert_eq!(out.snapshot.weekly_used, 26);
562        assert_eq!(out.snapshot.weekly_remaining, 74);
563        assert_eq!(out.snapshot.window_limit, 100);
564        assert_eq!(out.snapshot.window_used, 15);
565        assert!(!out.stale);
566    }
567
568    #[tokio::test]
569    async fn http_401_falls_back_to_cache() {
570        let mut server = mockito::Server::new_async().await;
571        server
572            .mock("GET", "/coding/v1/usages")
573            .with_status(401)
574            .with_body(r#"{"error": "invalid api key"}"#)
575            .create_async()
576            .await;
577
578        let (_td, cache) = cache_fixture();
579        cache
580            .write_payload(sample_seed().to_string().as_bytes())
581            .unwrap();
582
583        let client = reqwest::Client::new();
584        let endpoints = test_endpoints(&server.url());
585        let out = fetch_snapshot(
586            &client,
587            "bad-key",
588            &cache,
589            &endpoints,
590            Duration::from_secs(0),
591        )
592        .await
593        .unwrap();
594        assert!(out.stale);
595        assert_eq!(out.snapshot.weekly_used, 30);
596        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
597    }
598
599    #[tokio::test]
600    async fn http_500_falls_back_to_cache() {
601        let mut server = mockito::Server::new_async().await;
602        server
603            .mock("GET", "/coding/v1/usages")
604            .with_status(500)
605            .with_body(r#"{"error": "internal server error"}"#)
606            .create_async()
607            .await;
608
609        let (_td, cache) = cache_fixture();
610        cache
611            .write_payload(sample_seed().to_string().as_bytes())
612            .unwrap();
613
614        let client = reqwest::Client::new();
615        let endpoints = test_endpoints(&server.url());
616        let out = fetch_snapshot(
617            &client,
618            "sk-test",
619            &cache,
620            &endpoints,
621            Duration::from_secs(0),
622        )
623        .await
624        .unwrap();
625        assert!(out.stale);
626        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
627    }
628
629    #[tokio::test]
630    async fn http_401_without_cache_returns_http_error() {
631        let mut server = mockito::Server::new_async().await;
632        server
633            .mock("GET", "/coding/v1/usages")
634            .with_status(401)
635            .with_body(r#"{"error": "invalid api key"}"#)
636            .create_async()
637            .await;
638
639        let (_td, cache) = cache_fixture();
640        let client = reqwest::Client::new();
641        let endpoints = test_endpoints(&server.url());
642        let err = fetch_snapshot(
643            &client,
644            "bad-key",
645            &cache,
646            &endpoints,
647            Duration::from_secs(0),
648        )
649        .await
650        .unwrap_err();
651        match err {
652            AppError::Http { status, .. } => assert_eq!(status, 401),
653            other => panic!("expected Http 401, got {other:?}"),
654        }
655    }
656
657    #[tokio::test]
658    async fn malformed_numeric_200_returns_schema_error() {
659        let mut server = mockito::Server::new_async().await;
660        server
661            .mock("GET", "/coding/v1/usages")
662            .with_status(200)
663            .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
664            .create_async()
665            .await;
666
667        let (_td, cache) = cache_fixture();
668        let client = reqwest::Client::new();
669        let endpoints = test_endpoints(&server.url());
670        let err = fetch_snapshot(
671            &client,
672            "sk-test",
673            &cache,
674            &endpoints,
675            Duration::from_secs(0),
676        )
677        .await
678        .unwrap_err();
679        assert!(
680            err.to_string().contains("used") || err.to_string().contains("Schema"),
681            "expected schema error, got {err}"
682        );
683    }
684
685    #[tokio::test]
686    async fn malformed_numeric_200_with_seeded_cache_returns_stale_snapshot_and_preserves_cache() {
687        let mut server = mockito::Server::new_async().await;
688        server
689            .mock("GET", "/coding/v1/usages")
690            .with_status(200)
691            .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
692            .create_async()
693            .await;
694
695        let (_td, cache) = cache_fixture();
696        let seeded = sample_seed().to_string();
697        cache.write_payload(seeded.as_bytes()).unwrap();
698
699        let client = reqwest::Client::new();
700        let endpoints = test_endpoints(&server.url());
701        let out = fetch_snapshot(
702            &client,
703            "sk-test",
704            &cache,
705            &endpoints,
706            Duration::from_secs(0),
707        )
708        .await
709        .unwrap();
710
711        assert!(out.stale);
712        assert_eq!(out.snapshot.weekly_used, 30);
713        assert_eq!(out.snapshot.window_used, 20);
714        assert_eq!(out.last_error, Some((0, SCHEMA_DRIFT_MESSAGE.into())));
715
716        // The payload file must still contain the original seeded snapshot.
717        let payload = std::fs::read_to_string(cache.payload_path()).unwrap();
718        assert_eq!(payload, seeded);
719    }
720
721    #[tokio::test]
722    async fn error_object_200_returns_schema_error() {
723        let mut server = mockito::Server::new_async().await;
724        server
725            .mock("GET", "/coding/v1/usages")
726            .with_status(200)
727            .with_body(r#"{"error": "invalid token"}"#)
728            .create_async()
729            .await;
730
731        let (_td, cache) = cache_fixture();
732        let client = reqwest::Client::new();
733        let endpoints = test_endpoints(&server.url());
734        let err = fetch_snapshot(
735            &client,
736            "sk-test",
737            &cache,
738            &endpoints,
739            Duration::from_secs(0),
740        )
741        .await
742        .unwrap_err();
743        assert!(err.to_string().contains("usage block"), "got {err}");
744    }
745
746    #[tokio::test]
747    async fn corrupt_fresh_cache_ignored() {
748        let mut server = mockito::Server::new_async().await;
749        server
750            .mock("GET", "/coding/v1/usages")
751            .with_status(200)
752            .with_body(sample_json())
753            .create_async()
754            .await;
755
756        let (_td, cache) = cache_fixture();
757        cache.write_payload(b"not valid json".as_slice()).unwrap();
758
759        let client = reqwest::Client::new();
760        let endpoints = test_endpoints(&server.url());
761        let out = fetch_snapshot(
762            &client,
763            "sk-test",
764            &cache,
765            &endpoints,
766            Duration::from_secs(60),
767        )
768        .await
769        .unwrap();
770        assert_eq!(out.snapshot.weekly_used, 26);
771        assert!(!out.stale);
772    }
773
774    #[tokio::test]
775    async fn a_fresh_legacy_plan_cache_is_upgraded_through_the_profile_endpoint() {
776        let mut server = mockito::Server::new_async().await;
777        let usages = server
778            .mock("GET", "/coding/v1/usages")
779            .with_status(200)
780            .with_body(sample_json())
781            .create_async()
782            .await;
783        let me = server
784            .mock("GET", "/coding/v1/me")
785            .with_status(200)
786            .with_body(r#"{"user_level_name":"Allegretto"}"#)
787            .create_async()
788            .await;
789
790        let (_td, cache) = cache_fixture();
791        cache
792            .write_payload(sample_seed().to_string().as_bytes())
793            .unwrap();
794
795        let out = fetch_snapshot(
796            &reqwest::Client::new(),
797            "sk-test",
798            &cache,
799            &test_endpoints(&server.url()),
800            Duration::from_secs(60),
801        )
802        .await
803        .unwrap();
804
805        usages.assert_async().await;
806        me.assert_async().await;
807        assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
808        let cached: serde_json::Value =
809            serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
810        assert_eq!(cached["plan"], "Allegretto");
811    }
812
813    #[test]
814    fn a_legacy_plan_is_humanized_when_only_fallback_cache_is_available() {
815        let bytes = sample_seed().to_string();
816        let snap = parse_cache(bytes.as_bytes()).unwrap();
817        assert_eq!(snap.plan, Some("Intermediate".into()));
818    }
819
820    #[tokio::test]
821    async fn corrupt_stale_cache_returns_error() {
822        let mut server = mockito::Server::new_async().await;
823        server
824            .mock("GET", "/coding/v1/usages")
825            .with_status(401)
826            .with_body(r#"{"error": "invalid api key"}"#)
827            .create_async()
828            .await;
829
830        let (_td, cache) = cache_fixture();
831        cache.write_payload(b"not valid json".as_slice()).unwrap();
832
833        let client = reqwest::Client::new();
834        let endpoints = test_endpoints(&server.url());
835        let err = fetch_snapshot(
836            &client,
837            "bad-key",
838            &cache,
839            &endpoints,
840            Duration::from_secs(0),
841        )
842        .await
843        .unwrap_err();
844        assert!(
845            matches!(err, AppError::Http { status, .. } if status == 401),
846            "expected 401, got {err:?}"
847        );
848    }
849
850    #[tokio::test]
851    async fn transport_error_with_stale_cache_uses_cache() {
852        // Use a URL that will not resolve to trigger a transport error.
853        let (_td, cache) = cache_fixture();
854        cache
855            .write_payload(sample_seed().to_string().as_bytes())
856            .unwrap();
857
858        let client = reqwest::Client::new();
859        let endpoints = test_endpoints("http://localhost:1");
860        let out = fetch_snapshot(
861            &client,
862            "sk-test",
863            &cache,
864            &endpoints,
865            Duration::from_secs(0),
866        )
867        .await
868        .unwrap();
869        assert!(out.stale);
870        assert_eq!(out.snapshot.weekly_used, 30);
871    }
872
873    #[tokio::test]
874    async fn missing_counters_with_seeded_cache_preserves_snapshot() {
875        let mut server = mockito::Server::new_async().await;
876        server
877            .mock("GET", "/coding/v1/usages")
878            .with_status(200)
879            .with_body(r#"{"usage":{"limit":100}}"#)
880            .create_async()
881            .await;
882        let (_td, cache) = cache_fixture();
883        let seeded = sample_seed().to_string();
884        cache.write_payload(seeded.as_bytes()).unwrap();
885        let out = fetch_snapshot(
886            &reqwest::Client::new(),
887            "sk-test",
888            &cache,
889            &test_endpoints(&server.url()),
890            Duration::ZERO,
891        )
892        .await
893        .unwrap();
894        assert!(out.stale);
895        assert_eq!(out.snapshot.weekly_used, 30);
896        assert_eq!(
897            std::fs::read_to_string(cache.payload_path()).unwrap(),
898            seeded
899        );
900    }
901
902    #[tokio::test]
903    async fn unrecognized_window_with_seeded_cache_preserves_snapshot() {
904        let mut server = mockito::Server::new_async().await;
905        server.mock("GET", "/coding/v1/usages").with_status(200)
906            .with_body(r#"{"usage":{"limit":100,"used":10},"limits":[{"window":{"duration":4,"timeUnit":"TIME_UNIT_HOUR"},"detail":{"limit":100,"used":10}}]}"#).create_async().await;
907        let (_td, cache) = cache_fixture();
908        let seeded = sample_seed().to_string();
909        cache.write_payload(seeded.as_bytes()).unwrap();
910        let out = fetch_snapshot(
911            &reqwest::Client::new(),
912            "sk-test",
913            &cache,
914            &test_endpoints(&server.url()),
915            Duration::ZERO,
916        )
917        .await
918        .unwrap();
919        assert!(out.stale);
920        assert_eq!(out.snapshot.window_used, 20);
921        assert_eq!(
922            std::fs::read_to_string(cache.payload_path()).unwrap(),
923            seeded
924        );
925    }
926
927    #[tokio::test]
928    async fn http_error_body_is_redacted() {
929        let mut server = mockito::Server::new_async().await;
930        server
931            .mock("GET", "/coding/v1/usages")
932            .with_status(500)
933            .with_body("proxy secret: <token>")
934            .create_async()
935            .await;
936        let (_td, cache) = cache_fixture();
937        let err = fetch_snapshot(
938            &reqwest::Client::new(),
939            "sk-test",
940            &cache,
941            &test_endpoints(&server.url()),
942            Duration::ZERO,
943        )
944        .await
945        .unwrap_err();
946        assert!(
947            matches!(err, AppError::Http { status: 500, ref body } if body == "Kimi API returned HTTP 500")
948        );
949    }
950
951    // ---- Kimi Code CLI (subscription) credential path ----
952
953    /// A kimi-code home with a stored login. `expires_at` is relative to
954    /// `NOW_SECS`, the instant every OAuth test below passes in.
955    const NOW_SECS: i64 = 1_800_000_000;
956
957    fn kimi_code_home(td: &TempDir, expires_in: i64) -> (PathBuf, KimiCodeAuth) {
958        let home = td.path().join(".kimi-code");
959        let auth = KimiCodeAuth::in_home(&home);
960        std::fs::create_dir_all(auth.credentials_path.parent().unwrap()).unwrap();
961        std::fs::write(
962            &auth.credentials_path,
963            serde_json::json!({
964                "access_token": "cli-at",
965                "refresh_token": "cli-rt",
966                "expires_at": NOW_SECS + expires_in,
967                "expires_in": 900,
968                "scope": "kimi-code",
969                "token_type": "Bearer",
970            })
971            .to_string(),
972        )
973        .unwrap();
974        (home, auth)
975    }
976
977    fn now() -> DateTime<Utc> {
978        DateTime::from_timestamp(NOW_SECS, 0).unwrap()
979    }
980
981    #[tokio::test]
982    async fn a_valid_cli_token_is_used_as_is_and_never_refreshed() {
983        let mut server = mockito::Server::new_async().await;
984        let usages = server
985            .mock("GET", "/coding/v1/usages")
986            .match_header("authorization", "Bearer cli-at")
987            .with_status(200)
988            .with_body(sample_json())
989            .create_async()
990            .await;
991        let refresh = server
992            .mock("POST", "/api/oauth/token")
993            .expect(0)
994            .create_async()
995            .await;
996
997        let (td, cache) = cache_fixture();
998        let (_home, auth) = kimi_code_home(&td, 600);
999        let out = fetch_snapshot_at(
1000            &reqwest::Client::new(),
1001            &Auth::KimiCode(auth.clone()),
1002            &cache,
1003            &test_endpoints(&server.url()),
1004            Duration::ZERO,
1005            now(),
1006        )
1007        .await
1008        .unwrap();
1009
1010        usages.assert_async().await;
1011        refresh.assert_async().await;
1012        assert_eq!(out.snapshot.weekly_used, 26);
1013        let stored = std::fs::read_to_string(&auth.credentials_path).unwrap();
1014        assert!(stored.contains("cli-rt"), "an unused token must not rotate");
1015    }
1016
1017    #[tokio::test]
1018    async fn an_expiring_cli_token_is_refreshed_and_the_rotation_is_written_back() {
1019        let mut server = mockito::Server::new_async().await;
1020        let refresh = server
1021            .mock("POST", "/api/oauth/token")
1022            .match_body(mockito::Matcher::UrlEncoded(
1023                "refresh_token".into(),
1024                "cli-rt".into(),
1025            ))
1026            .with_status(200)
1027            .with_body(
1028                r#"{"access_token":"fresh-at","refresh_token":"fresh-rt","expires_in":900,
1029                    "scope":"kimi-code","token_type":"Bearer"}"#,
1030            )
1031            .create_async()
1032            .await;
1033        let usages = server
1034            .mock("GET", "/coding/v1/usages")
1035            .match_header("authorization", "Bearer fresh-at")
1036            .with_status(200)
1037            .with_body(sample_json())
1038            .create_async()
1039            .await;
1040
1041        let (td, cache) = cache_fixture();
1042        // Inside the refresh buffer: still valid, but not for long enough.
1043        let (home, auth) = kimi_code_home(&td, 30);
1044        let out = fetch_snapshot_at(
1045            &reqwest::Client::new(),
1046            &Auth::KimiCode(auth.clone()),
1047            &cache,
1048            &test_endpoints(&server.url()),
1049            Duration::ZERO,
1050            now(),
1051        )
1052        .await
1053        .unwrap();
1054
1055        refresh.assert_async().await;
1056        usages.assert_async().await;
1057        assert_eq!(out.snapshot.weekly_used, 26);
1058
1059        let stored: serde_json::Value =
1060            serde_json::from_slice(&std::fs::read(&auth.credentials_path).unwrap()).unwrap();
1061        assert_eq!(stored["access_token"], "fresh-at");
1062        assert_eq!(
1063            stored["refresh_token"], "fresh-rt",
1064            "the CLI's own store must carry the rotated token, or its next run is dead"
1065        );
1066        assert_eq!(stored["expires_at"], NOW_SECS + 900);
1067        // The lock is the CLI's own target, and it must not be left behind.
1068        assert!(!super::super::lock::lock_dir_for(&auth.lock_target).exists());
1069        assert!(home.join("oauth").is_dir());
1070    }
1071
1072    #[tokio::test]
1073    async fn a_rejected_refresh_reports_a_credential_error_naming_the_cli() {
1074        let mut server = mockito::Server::new_async().await;
1075        server
1076            .mock("POST", "/api/oauth/token")
1077            .with_status(401)
1078            .with_body(r#"{"error":"invalid_grant"}"#)
1079            .create_async()
1080            .await;
1081
1082        let (td, cache) = cache_fixture();
1083        let (_home, auth) = kimi_code_home(&td, -60);
1084        let err = fetch_snapshot_at(
1085            &reqwest::Client::new(),
1086            &Auth::KimiCode(auth),
1087            &cache,
1088            &test_endpoints(&server.url()),
1089            Duration::ZERO,
1090            now(),
1091        )
1092        .await
1093        .unwrap_err();
1094        let message = err.to_string();
1095        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
1096        assert!(message.contains("log in again"), "{message}");
1097    }
1098
1099    #[tokio::test]
1100    async fn a_logged_out_cli_falls_back_to_cache_with_a_credential_warning() {
1101        let (td, cache) = cache_fixture();
1102        let (_home, auth) = kimi_code_home(&td, 600);
1103        std::fs::write(
1104            &auth.credentials_path,
1105            r#"{"access_token":"","refresh_token":"","expires_at":0}"#,
1106        )
1107        .unwrap();
1108        cache
1109            .write_payload(sample_seed().to_string().as_bytes())
1110            .unwrap();
1111
1112        let out = fetch_snapshot_at(
1113            &reqwest::Client::new(),
1114            &Auth::KimiCode(auth),
1115            &cache,
1116            &test_endpoints("http://localhost:1"),
1117            Duration::ZERO,
1118            now(),
1119        )
1120        .await
1121        .unwrap();
1122        assert!(out.stale);
1123        assert_eq!(out.snapshot.weekly_used, 30);
1124        let (code, message) = out.last_error.unwrap();
1125        assert_eq!(code, 0);
1126        assert!(message.contains("logged out"), "{message}");
1127    }
1128
1129    #[tokio::test]
1130    async fn a_peer_refresh_during_the_wait_is_picked_up_instead_of_rotating_again() {
1131        let mut server = mockito::Server::new_async().await;
1132        let refresh = server
1133            .mock("POST", "/api/oauth/token")
1134            .expect(0)
1135            .create_async()
1136            .await;
1137        let usages = server
1138            .mock("GET", "/coding/v1/usages")
1139            .match_header("authorization", "Bearer peer-at")
1140            .with_status(200)
1141            .with_body(sample_json())
1142            .create_async()
1143            .await;
1144
1145        let (td, cache) = cache_fixture();
1146        let (_home, auth) = kimi_code_home(&td, -60);
1147        // Stand in for the CLI finishing its own refresh while we queued: the
1148        // re-read under the lock must win over the copy read before it.
1149        let peer = serde_json::json!({
1150            "access_token": "peer-at",
1151            "refresh_token": "peer-rt",
1152            "expires_at": NOW_SECS + 900,
1153            "expires_in": 900,
1154            "scope": "kimi-code",
1155            "token_type": "Bearer",
1156        });
1157        std::fs::write(&auth.credentials_path, peer.to_string()).unwrap();
1158
1159        let out = fetch_snapshot_at(
1160            &reqwest::Client::new(),
1161            &Auth::KimiCode(auth),
1162            &cache,
1163            &test_endpoints(&server.url()),
1164            Duration::ZERO,
1165            now(),
1166        )
1167        .await
1168        .unwrap();
1169        refresh.assert_async().await;
1170        usages.assert_async().await;
1171        assert_eq!(out.snapshot.weekly_used, 26);
1172    }
1173
1174    #[test]
1175    fn endpoints_follow_the_region() {
1176        let cn = Endpoints::for_region(Region::MainlandCn);
1177        assert_eq!(cn.usages, "https://api.kimi.com/coding/v1/usages");
1178        assert_eq!(cn.token, "https://auth.kimi.com/api/oauth/token");
1179        let global = Endpoints::for_region(Region::Global);
1180        assert_eq!(global.usages, "https://api.kimi.ai/coding/v1/usages");
1181        assert_eq!(global.token, "https://auth.kimi.ai/api/oauth/token");
1182        // The default stays the endpoint the API-key path has always used.
1183        assert_eq!(Endpoints::default().usages, cn.usages);
1184    }
1185
1186    #[tokio::test]
1187    async fn the_vendors_own_tier_name_replaces_the_wire_enum() {
1188        let mut server = mockito::Server::new_async().await;
1189        server
1190            .mock("GET", "/coding/v1/usages")
1191            .with_status(200)
1192            .with_body(sample_json())
1193            .create_async()
1194            .await;
1195        let me = server
1196            .mock("GET", "/coding/v1/me")
1197            .match_header("authorization", "Bearer sk-test")
1198            .with_status(200)
1199            .with_body(r#"{"user_id":"u-1","user_level":25,"user_level_name":"Allegretto"}"#)
1200            .create_async()
1201            .await;
1202
1203        let (_td, cache) = cache_fixture();
1204        let out = fetch_snapshot(
1205            &reqwest::Client::new(),
1206            "sk-test",
1207            &cache,
1208            &test_endpoints(&server.url()),
1209            Duration::ZERO,
1210        )
1211        .await
1212        .unwrap();
1213        me.assert_async().await;
1214        assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
1215        // …and it survives the cache round-trip, not just the live fetch.
1216        let cached: serde_json::Value =
1217            serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
1218        assert_eq!(cached["plan"], "Allegretto");
1219    }
1220
1221    #[tokio::test]
1222    async fn a_profile_endpoint_that_fails_costs_the_label_and_nothing_else() {
1223        // 404 is documented by kimi-code's own error text for accounts with no
1224        // coding profile; the quota numbers must still come through.
1225        for status in [404, 401, 500] {
1226            let mut server = mockito::Server::new_async().await;
1227            server
1228                .mock("GET", "/coding/v1/usages")
1229                .with_status(200)
1230                .with_body(sample_json())
1231                .create_async()
1232                .await;
1233            server
1234                .mock("GET", "/coding/v1/me")
1235                .with_status(status)
1236                .with_body(r#"{"error":"nope"}"#)
1237                .create_async()
1238                .await;
1239
1240            let (_td, cache) = cache_fixture();
1241            let out = fetch_snapshot(
1242                &reqwest::Client::new(),
1243                "sk-test",
1244                &cache,
1245                &test_endpoints(&server.url()),
1246                Duration::ZERO,
1247            )
1248            .await
1249            .unwrap();
1250            assert_eq!(out.snapshot.plan, Some("Intermediate".into()), "{status}");
1251            assert_eq!(out.snapshot.weekly_used, 26, "{status}");
1252            assert!(out.last_error.is_none(), "{status}: must not warn");
1253        }
1254    }
1255
1256    #[tokio::test]
1257    async fn an_unreachable_profile_endpoint_does_not_fail_the_fetch() {
1258        let mut server = mockito::Server::new_async().await;
1259        server
1260            .mock("GET", "/coding/v1/usages")
1261            .with_status(200)
1262            .with_body(sample_json())
1263            .create_async()
1264            .await;
1265        let mut endpoints = test_endpoints(&server.url());
1266        endpoints.me = "http://localhost:1/coding/v1/me".into();
1267
1268        let (_td, cache) = cache_fixture();
1269        let out = fetch_snapshot(
1270            &reqwest::Client::new(),
1271            "sk-test",
1272            &cache,
1273            &endpoints,
1274            Duration::ZERO,
1275        )
1276        .await
1277        .unwrap();
1278        assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
1279        assert!(!out.stale);
1280    }
1281
1282    #[test]
1283    fn a_relocated_credential_file_keeps_the_homes_lock_target() {
1284        let home = Path::new("/home/u/.kimi-code");
1285        let auth =
1286            KimiCodeAuth::with_credentials_path(home, PathBuf::from("/elsewhere/kimi-code.json"));
1287        assert_eq!(
1288            auth.credentials_path,
1289            PathBuf::from("/elsewhere/kimi-code.json")
1290        );
1291        assert_eq!(auth.lock_target, oauth::lock_target_in(home));
1292    }
1293
1294    fn monthly_shape_snap() -> KimiSnapshot {
1295        KimiSnapshot {
1296            plan: Some("Allegretto".into()),
1297            weekly_limit: 0,
1298            weekly_used: 0,
1299            weekly_remaining: 0,
1300            weekly_reset_at: None,
1301            has_weekly: false,
1302            monthly_pct: Some(42),
1303            monthly_reset_at: Some(
1304                DateTime::parse_from_rfc3339("2026-10-16T00:00:00Z")
1305                    .unwrap()
1306                    .into(),
1307            ),
1308            window_limit: 100,
1309            window_used: 15,
1310            window_remaining: 85,
1311            window_reset_at: Some(
1312                DateTime::parse_from_rfc3339("2026-09-16T20:11:32Z")
1313                    .unwrap()
1314                    .into(),
1315            ),
1316        }
1317    }
1318
1319    #[test]
1320    fn a_monthly_shape_snapshot_survives_the_cache_round_trip() {
1321        let bytes = serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap();
1322        let snap = parse_cache(&bytes).unwrap();
1323        assert!(!snap.has_weekly);
1324        assert_eq!(snap.monthly_pct, Some(42));
1325        assert_eq!(
1326            snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
1327            Some("2026-10-16T00:00:00+00:00".to_string())
1328        );
1329        assert_eq!(snap.weekly_used, 0);
1330        assert_eq!(snap.window_used, 15);
1331        assert_eq!(snap, monthly_shape_snap());
1332    }
1333
1334    #[test]
1335    fn a_legacy_cache_without_the_new_keys_still_parses() {
1336        // sample_seed predates the monthly shape: no has_weekly/monthly keys.
1337        let bytes = sample_seed().to_string();
1338        let snap = parse_cache(bytes.as_bytes()).unwrap();
1339        assert!(snap.has_weekly);
1340        assert_eq!(snap.monthly_pct, None);
1341        assert_eq!(snap.monthly_reset_at, None);
1342        assert_eq!(snap.weekly_used, 30);
1343    }
1344
1345    #[test]
1346    fn a_cache_with_weekly_counters_on_a_monthly_shape_is_rejected() {
1347        let mut v = sample_seed();
1348        v["has_weekly"] = serde_json::json!(false);
1349        let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1350        assert!(err.to_string().contains("monthly-shape"), "{err}");
1351    }
1352
1353    #[test]
1354    fn a_cache_with_an_out_of_range_monthly_pct_is_rejected() {
1355        for bad in [150, -1] {
1356            let mut v: serde_json::Value = serde_json::from_slice(
1357                &serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap(),
1358            )
1359            .unwrap();
1360            v["monthly_pct"] = serde_json::json!(bad);
1361            let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1362            assert!(err.to_string().contains("monthly_pct"), "{bad}: {err}");
1363        }
1364    }
1365}