Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions rust/src/providers/claude/oauth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,70 @@ mod tests {
);
}

#[test]
fn issue_210_reporter_shape_secondary_is_one_percent_not_one_hundred() {
// Mirrors the reporter JSON: session 8%, fable 2%, all-models should be 1%
// while seven_day.utilization is the stale 1.0 (would display as 100%).
let response: OAuthUsageResponse = serde_json::from_str(
r#"{
"five_hour": {
"utilization": 8.0,
"resets_at": "2026-07-20T04:29:59.671218Z"
},
"seven_day": {
"utilization": 1.0,
"resets_at": "2026-07-26T22:59:59.671246Z"
},
"limits": [
{
"kind": "weekly_all",
"group": "weekly",
"percent": 1.0,
"resets_at": "2026-07-26T22:59:59.671595Z"
},
{
"kind": "weekly_scoped",
"group": "weekly",
"percent": 2.0,
"resets_at": "2026-07-26T22:59:59.671595Z",
"scope": {
"model": {
"id": "claude-fable",
"display_name": "Fable"
}
}
}
]
}"#,
)
.expect("issue 210 body");

let credentials = ClaudeOAuthCredentials {
access_token: "token".to_string(),
refresh_token: None,
expires_at: None,
scopes: vec![],
rate_limit_tier: Some("default_claude_max_5x".to_string()),
};
let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials);

assert_eq!(usage.login_method.as_deref(), Some("Claude Max 5x"));
assert!((usage.primary.used_percent - 8.0).abs() < f64::EPSILON);
let weekly = usage.secondary.expect("secondary weekly");
assert!(
(weekly.used_percent - 1.0).abs() < f64::EPSILON,
"secondary was {}, expected 1% (not 100%)",
weekly.used_percent
);
assert!((weekly.used_percent - 100.0).abs() > 1.0);
let fable = usage
.extra_rate_windows
.iter()
.find(|w| w.title.contains("Fable"))
.expect("Fable only window");
assert!((fable.window.used_percent - 2.0).abs() < f64::EPSILON);
}

#[test]
fn parses_retry_after_seconds() {
let header = HeaderValue::from_static("17");
Expand Down
52 changes: 49 additions & 3 deletions rust/src/providers/cursor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ impl CursorProvider {
ctx: &FetchContext,
) -> Result<api::CursorUsageResult, ProviderError> {
match ctx.source_mode {
SourceMode::Auto | SourceMode::Web => self.fetch_web_usage_parts(ctx).await,
SourceMode::Cli | SourceMode::OAuth => {
Err(ProviderError::UnsupportedSource(ctx.source_mode))
// Cli is only ever set by the shell for "no cookie yet"; treat it as
// web so empty-manual users get browser cookie attempt (or AuthRequired)
// instead of "Source mode 'Cli' not supported" (#212).
SourceMode::Auto | SourceMode::Web | SourceMode::Cli => {
self.fetch_web_usage_parts(ctx).await
}
SourceMode::OAuth => Err(ProviderError::UnsupportedSource(ctx.source_mode)),
}
}

Expand Down Expand Up @@ -138,3 +141,46 @@ impl Provider for CursorProvider {
true
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::core::FetchContext;

#[tokio::test]
async fn cli_mode_does_not_return_unsupported_source() {
let provider = CursorProvider::new();
let ctx = FetchContext {
source_mode: SourceMode::Cli,
manual_cookie_header: None,
..FetchContext::default()
};
let err = provider
.fetch_usage(&ctx)
.await
.expect_err("no cookies on this machine");
// Must not be UnsupportedSource — that was the user-visible #212 bug.
assert!(
!matches!(err, ProviderError::UnsupportedSource(_)),
"unexpected UnsupportedSource: {err}"
);
assert!(
matches!(
err,
ProviderError::NoCookies | ProviderError::AuthRequired | ProviderError::Other(_)
),
"expected cookie/auth style error, got: {err}"
);
}

#[tokio::test]
async fn oauth_mode_still_unsupported() {
let provider = CursorProvider::new();
let ctx = FetchContext {
source_mode: SourceMode::OAuth,
..FetchContext::default()
};
let err = provider.fetch_usage(&ctx).await.expect_err("oauth unsupported");
assert!(matches!(err, ProviderError::UnsupportedSource(SourceMode::OAuth)));
}
}
46 changes: 46 additions & 0 deletions rust/src/providers/opencode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,4 +719,50 @@ mod tests {
// 0.25 fraction scales to 25%
assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON);
}

#[test]
fn parse_subscription_accepts_realistic_server_fn_body() {
let provider = OpenCodeProvider::new();
// Shape closer to a live server-fn payload: nested under result/data.
let body = r#"{
"result": {
"data": {
"rollingUsage": { "usagePercent": 33.0, "resetInSec": 1800 },
"weeklyUsage": { "usagePercent": 12.5, "resetInSec": 604800 },
"renewAt": "2026-08-01T00:00:00Z"
}
}
}"#;
let snap = provider
.parse_subscription(body)
.expect("server-fn body should parse end-to-end");
assert!((snap.primary.used_percent - 33.0).abs() < f64::EPSILON);
assert!((snap.secondary.as_ref().unwrap().used_percent - 12.5).abs() < f64::EPSILON);
assert_eq!(snap.login_method.as_deref(), Some("OpenCode"));
}

#[tokio::test]
async fn web_mode_without_cookies_is_auth_not_unsupported() {
let provider = OpenCodeProvider::new();
let ctx = FetchContext {
source_mode: SourceMode::Web,
manual_cookie_header: None,
..FetchContext::default()
};
let err = provider
.fetch_usage(&ctx)
.await
.expect_err("no cookies available");
assert!(
!matches!(err, ProviderError::UnsupportedSource(_)),
"got UnsupportedSource: {err}"
);
assert!(
matches!(
err,
ProviderError::AuthRequired | ProviderError::NoCookies | ProviderError::Other(_)
),
"got: {err}"
);
}
}
Loading