use serde::Deserialize;
use crate::error::{AppError, Result};
use crate::usage::{GrokSnapshot, parse_amount};
pub const SCOPE_TEAM: &str = "SCOPE_TEAM";
pub const SCOPE_ORGANIZATION: &str = "SCOPE_ORGANIZATION";
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Validation {
pub scope: Option<String>,
#[serde(rename = "scopeId")]
pub scope_id: Option<String>,
#[serde(rename = "teamId")]
pub team_id: Option<String>,
}
impl Validation {
pub fn resolved_team(&self) -> Result<String> {
let non_empty =
|s: &Option<String>| s.as_deref().filter(|v| !v.is_empty()).map(String::from);
let scope = self
.scope
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_uppercase();
if scope == SCOPE_ORGANIZATION {
return non_empty(&self.team_id).ok_or_else(|| {
AppError::Other(
"grok: this management key is organization-scoped, so its `scopeId` is an \
organization id, not a team. Set `team_id` under [grok] in config to the \
team you want the prepaid balance for."
.into(),
)
});
}
if scope.is_empty() || scope == SCOPE_TEAM {
return non_empty(&self.scope_id)
.or_else(|| non_empty(&self.team_id))
.ok_or_else(|| {
AppError::Other(
"grok: could not resolve team_id from the management key; \
set `team_id` under [grok] in config"
.into(),
)
});
}
Err(AppError::Other(format!(
"grok: unrecognized management-key scope {scope:?}; \
set `team_id` under [grok] in config"
)))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Amount {
pub val: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BalanceResp {
pub total: Amount,
}
pub fn to_snapshot(b: BalanceResp) -> Result<GrokSnapshot> {
let cents = parse_amount("grok", "total.val", &b.total.val)?;
Ok(GrokSnapshot {
balance: -cents / 100.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_total_is_positive_remaining_balance() {
let b: BalanceResp = serde_json::from_str(r#"{"total":{"val":"-1000"}}"#).unwrap();
assert!((to_snapshot(b).unwrap().balance - 10.0).abs() < 1e-9);
}
#[test]
fn empty_or_malformed_total_is_a_schema_error_not_zero() {
assert!(serde_json::from_str::<BalanceResp>("{}").is_err());
assert!(serde_json::from_str::<BalanceResp>(r#"{"total":{}}"#).is_err());
let b: BalanceResp = serde_json::from_str(r#"{"total":{"val":""}}"#).unwrap();
assert!(to_snapshot(b).is_err());
let b2: BalanceResp = serde_json::from_str(r#"{"total":{"val":"n/a"}}"#).unwrap();
assert!(to_snapshot(b2).is_err());
}
#[test]
fn team_scoped_key_uses_scope_id() {
let v = Validation {
scope: Some(SCOPE_TEAM.into()),
scope_id: Some("team-1".into()),
team_id: None,
};
assert_eq!(v.resolved_team().unwrap(), "team-1");
}
#[test]
fn organization_scoped_key_does_not_use_scope_id_as_a_team() {
let v = Validation {
scope: Some(SCOPE_ORGANIZATION.into()),
scope_id: Some("org-123".into()),
team_id: None,
};
let err = v.resolved_team().unwrap_err().to_string();
assert!(!err.contains("org-123"), "must not adopt the org id: {err}");
assert!(
err.contains("team_id"),
"must tell the user what to do: {err}"
);
let v2 = Validation {
scope: Some(SCOPE_ORGANIZATION.into()),
scope_id: Some("org-123".into()),
team_id: Some("team-9".into()),
};
assert_eq!(v2.resolved_team().unwrap(), "team-9");
}
#[test]
fn legacy_response_without_scope_still_resolves() {
let v: Validation = serde_json::from_str(r#"{"scopeId":null,"teamId":"team-x"}"#).unwrap();
assert_eq!(v.resolved_team().unwrap(), "team-x");
let v2: Validation = serde_json::from_str(r#"{"scopeId":"team-y"}"#).unwrap();
assert_eq!(v2.resolved_team().unwrap(), "team-y");
let empty: Validation = serde_json::from_str("{}").unwrap();
assert!(empty.resolved_team().is_err());
}
#[test]
fn unknown_scope_is_rejected() {
let v = Validation {
scope: Some("SCOPE_GALAXY".into()),
scope_id: Some("x-1".into()),
team_id: None,
};
assert!(v.resolved_team().is_err());
}
}