use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::control::Page;
use crate::Error;
#[derive(Clone)]
pub struct AccountClient {
client: Client,
}
impl AccountClient {
pub(crate) fn new(client: Client) -> Self {
Self { client }
}
#[cfg(feature = "rest")]
pub async fn get(&self) -> Result<Account, Error> {
self.client.inner().control_get("/control/v1/account").await
}
#[cfg(not(feature = "rest"))]
pub async fn get(&self) -> Result<Account, Error> {
Err(Error::configuration(
"REST feature is required for account API",
))
}
pub async fn update(&self, request: UpdateAccountRequest) -> Result<Account, Error> {
#[cfg(feature = "rest")]
{
return self
.client
.inner()
.control_patch("/control/v1/users/me", &request)
.await;
}
#[cfg(not(feature = "rest"))]
{
let _ = request;
Err(Error::configuration("REST feature is required"))
}
}
pub fn emails(&self) -> EmailsClient {
EmailsClient {
client: self.client.clone(),
}
}
pub fn sessions(&self) -> SessionsClient {
SessionsClient {
client: self.client.clone(),
}
}
pub async fn change_password(&self, request: ChangePasswordRequest) -> Result<(), Error> {
#[cfg(feature = "rest")]
{
let _: serde_json::Value = self
.client
.inner()
.control_post("/control/v1/users/me/password", &request)
.await?;
Ok(())
}
#[cfg(not(feature = "rest"))]
{
let _ = request;
Err(Error::configuration("REST feature is required"))
}
}
}
impl std::fmt::Debug for AccountClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccountClient").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub email: String,
pub name: Option<String>,
pub status: AccountStatus,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub mfa_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountStatus {
Active,
Suspended,
PendingVerification,
}
impl AccountStatus {
pub fn is_active(&self) -> bool {
matches!(self, AccountStatus::Active)
}
pub fn is_suspended(&self) -> bool {
matches!(self, AccountStatus::Suspended)
}
pub fn is_pending_verification(&self) -> bool {
matches!(self, AccountStatus::PendingVerification)
}
}
impl std::fmt::Display for AccountStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AccountStatus::Active => write!(f, "active"),
AccountStatus::Suspended => write!(f, "suspended"),
AccountStatus::PendingVerification => write!(f, "pending_verification"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateAccountRequest {
pub name: Option<String>,
}
impl UpdateAccountRequest {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
impl ChangePasswordRequest {
pub fn new(current_password: impl Into<String>, new_password: impl Into<String>) -> Self {
Self {
current_password: current_password.into(),
new_password: new_password.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Email {
pub address: String,
pub verified: bool,
pub primary: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Clone)]
pub struct EmailsClient {
client: Client,
}
impl EmailsClient {
pub async fn list(&self) -> Result<Page<Email>, Error> {
#[cfg(feature = "rest")]
{
return self
.client
.inner()
.control_get("/control/v1/users/emails")
.await;
}
#[cfg(not(feature = "rest"))]
Err(Error::configuration("REST feature is required"))
}
pub async fn add(&self, address: impl Into<String>) -> Result<Email, Error> {
let address = address.into();
#[cfg(feature = "rest")]
{
#[derive(serde::Serialize)]
struct AddEmailRequest {
email: String,
}
return self
.client
.inner()
.control_post(
"/control/v1/users/emails",
&AddEmailRequest { email: address },
)
.await;
}
#[cfg(not(feature = "rest"))]
{
let _ = address;
Err(Error::configuration("REST feature is required"))
}
}
pub async fn remove(&self, address: impl Into<String>) -> Result<(), Error> {
let address = address.into();
#[cfg(feature = "rest")]
{
let encoded = urlencoding::encode(&address);
let path = format!("/control/v1/users/emails/{}", encoded);
return self.client.inner().control_delete(&path).await;
}
#[cfg(not(feature = "rest"))]
{
let _ = address;
Err(Error::configuration("REST feature is required"))
}
}
pub async fn set_primary(&self, address: impl Into<String>) -> Result<(), Error> {
let address = address.into();
#[cfg(feature = "rest")]
{
#[derive(serde::Serialize)]
struct SetPrimaryRequest {
primary: bool,
}
let encoded = urlencoding::encode(&address);
let path = format!("/control/v1/users/emails/{}", encoded);
let _: Email = self
.client
.inner()
.control_patch(&path, &SetPrimaryRequest { primary: true })
.await?;
Ok(())
}
#[cfg(not(feature = "rest"))]
{
let _ = address;
Err(Error::configuration("REST feature is required"))
}
}
pub async fn resend_verification(&self, address: impl Into<String>) -> Result<(), Error> {
let address = address.into();
#[cfg(feature = "rest")]
{
let encoded = urlencoding::encode(&address);
let path = format!("/control/v1/users/emails/{}/resend-verification", encoded);
let _: serde_json::Value = self.client.inner().control_post_empty(&path).await?;
Ok(())
}
#[cfg(not(feature = "rest"))]
{
let _ = address;
Err(Error::configuration("REST feature is required"))
}
}
}
impl std::fmt::Debug for EmailsClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailsClient").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub current: bool,
}
#[derive(Clone)]
pub struct SessionsClient {
client: Client,
}
impl SessionsClient {
pub async fn list(&self) -> Result<Page<Session>, Error> {
#[cfg(feature = "rest")]
{
return self
.client
.inner()
.control_get("/control/v1/users/sessions")
.await;
}
#[cfg(not(feature = "rest"))]
Err(Error::configuration("REST feature is required"))
}
pub async fn revoke(&self, session_id: impl Into<String>) -> Result<(), Error> {
let session_id = session_id.into();
#[cfg(feature = "rest")]
{
let path = format!("/control/v1/users/sessions/{}", session_id);
return self.client.inner().control_delete(&path).await;
}
#[cfg(not(feature = "rest"))]
{
let _ = session_id;
Err(Error::configuration("REST feature is required"))
}
}
pub async fn revoke_all_others(&self) -> Result<(), Error> {
#[cfg(feature = "rest")]
{
let _: serde_json::Value = self
.client
.inner()
.control_post_empty("/control/v1/users/sessions/revoke-others")
.await?;
Ok(())
}
#[cfg(not(feature = "rest"))]
Err(Error::configuration("REST feature is required"))
}
pub async fn revoke_all(&self) -> Result<(), Error> {
#[cfg(feature = "rest")]
{
let _: serde_json::Value = self
.client
.inner()
.control_post_empty("/control/v1/users/sessions/revoke-all")
.await?;
Ok(())
}
#[cfg(not(feature = "rest"))]
Err(Error::configuration("REST feature is required"))
}
}
impl std::fmt::Debug for SessionsClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionsClient").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::BearerCredentialsConfig;
use crate::transport::mock::MockTransport;
use std::sync::Arc;
async fn create_test_client() -> Client {
let mock_transport = Arc::new(MockTransport::new());
Client::builder()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("test"))
.build_with_transport(mock_transport)
.await
.unwrap()
}
#[test]
fn test_account_status() {
assert!(AccountStatus::Active.is_active());
assert!(!AccountStatus::Active.is_suspended());
assert!(!AccountStatus::Active.is_pending_verification());
assert!(!AccountStatus::Suspended.is_active());
assert!(AccountStatus::Suspended.is_suspended());
assert!(AccountStatus::PendingVerification.is_pending_verification());
}
#[test]
fn test_account_status_display() {
assert_eq!(AccountStatus::Active.to_string(), "active");
assert_eq!(AccountStatus::Suspended.to_string(), "suspended");
assert_eq!(
AccountStatus::PendingVerification.to_string(),
"pending_verification"
);
}
#[test]
fn test_update_account_request() {
let req = UpdateAccountRequest::new().with_name("Alice");
assert_eq!(req.name, Some("Alice".to_string()));
}
#[test]
fn test_change_password_request() {
let req = ChangePasswordRequest::new("old", "new");
assert_eq!(req.current_password, "old");
assert_eq!(req.new_password, "new");
}
#[tokio::test]
async fn test_debug_impls() {
let client = create_test_client().await;
let account = AccountClient::new(client.clone());
assert!(format!("{:?}", account).contains("AccountClient"));
assert!(format!("{:?}", account.emails()).contains("EmailsClient"));
assert!(format!("{:?}", account.sessions()).contains("SessionsClient"));
}
#[tokio::test]
async fn test_account_client_clone() {
let client = create_test_client().await;
let account = AccountClient::new(client);
let _cloned = account.clone();
}
#[tokio::test]
async fn test_emails_client_clone() {
let client = create_test_client().await;
let account = AccountClient::new(client);
let emails = account.emails();
let _cloned = emails.clone();
}
#[tokio::test]
async fn test_sessions_client_clone() {
let client = create_test_client().await;
let account = AccountClient::new(client);
let sessions = account.sessions();
let _cloned = sessions.clone();
}
#[test]
fn test_account_serde() {
let json = r#"{
"id": "usr_abc123",
"email": "test@example.com",
"name": "Test User",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"mfa_enabled": false
}"#;
let account: Account = serde_json::from_str(json).unwrap();
assert_eq!(account.id, "usr_abc123");
assert_eq!(account.email, "test@example.com");
assert_eq!(account.name, Some("Test User".to_string()));
assert!(account.status.is_active());
assert!(!account.mfa_enabled);
}
#[test]
fn test_account_clone() {
let account = Account {
id: "usr_abc123".to_string(),
email: "test@example.com".to_string(),
name: Some("Test".to_string()),
status: AccountStatus::Active,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
mfa_enabled: true,
};
let cloned = account.clone();
assert_eq!(cloned.id, "usr_abc123");
assert!(cloned.mfa_enabled);
}
#[test]
fn test_email_serde() {
let json = r#"{
"address": "test@example.com",
"verified": true,
"primary": true,
"created_at": "2024-01-01T00:00:00Z"
}"#;
let email: Email = serde_json::from_str(json).unwrap();
assert_eq!(email.address, "test@example.com");
assert!(email.verified);
assert!(email.primary);
}
#[test]
fn test_email_clone() {
let email = Email {
address: "test@example.com".to_string(),
verified: false,
primary: false,
created_at: chrono::Utc::now(),
};
let cloned = email.clone();
assert_eq!(cloned.address, "test@example.com");
assert!(!cloned.verified);
}
#[test]
fn test_session_serde() {
let json = r#"{
"id": "ses_abc123",
"created_at": "2024-01-01T00:00:00Z",
"expires_at": "2024-02-01T00:00:00Z",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0",
"current": true
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert_eq!(session.id, "ses_abc123");
assert!(session.current);
assert_eq!(session.ip_address, Some("192.168.1.1".to_string()));
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
}
#[test]
fn test_session_clone() {
let session = Session {
id: "ses_abc".to_string(),
created_at: chrono::Utc::now(),
expires_at: chrono::Utc::now(),
ip_address: None,
user_agent: None,
current: false,
};
let cloned = session.clone();
assert_eq!(cloned.id, "ses_abc");
assert!(!cloned.current);
}
#[test]
fn test_update_account_request_serde() {
let req = UpdateAccountRequest::new().with_name("New Name");
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("New Name"));
}
#[test]
fn test_change_password_request_serde() {
let req = ChangePasswordRequest::new("old_pass", "new_pass");
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("old_pass"));
assert!(json.contains("new_pass"));
}
#[test]
fn test_change_password_request_clone() {
let req = ChangePasswordRequest::new("old", "new");
let cloned = req.clone();
assert_eq!(cloned.current_password, "old");
assert_eq!(cloned.new_password, "new");
}
#[test]
fn test_update_account_request_clone() {
let req = UpdateAccountRequest::new().with_name("Test");
let cloned = req.clone();
assert_eq!(cloned.name, Some("Test".to_string()));
}
}
#[cfg(test)]
#[cfg(feature = "rest")]
mod wiremock_tests {
use super::*;
use crate::auth::BearerCredentialsConfig;
use crate::Client;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn create_mock_client(server: &MockServer) -> Client {
Client::builder()
.url(server.uri())
.insecure()
.credentials(BearerCredentialsConfig::new("test_token"))
.build()
.await
.unwrap()
}
#[tokio::test]
async fn test_get_account() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/account"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "usr_123",
"email": "user@example.com",
"name": "Test User",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"mfa_enabled": false
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().get().await;
assert!(result.is_ok());
let account = result.unwrap();
assert_eq!(account.id, "usr_123");
assert_eq!(account.email, "user@example.com");
}
#[tokio::test]
async fn test_get_account_unauthorized() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/account"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "Unauthorized"
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().get().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_update_account() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/control/v1/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "usr_123",
"email": "user@example.com",
"name": "Updated Name",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z",
"mfa_enabled": false
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let request = UpdateAccountRequest::new().with_name("Updated Name");
let result = client.account().update(request).await;
assert!(result.is_ok());
let account = result.unwrap();
assert_eq!(account.name, Some("Updated Name".to_string()));
}
#[tokio::test]
async fn test_change_password() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/control/v1/users/me/password"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let request = ChangePasswordRequest::new("old_pass", "new_pass");
let result = client.account().change_password(request).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_list_emails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/users/emails"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"address": "primary@example.com",
"verified": true,
"primary": true,
"created_at": "2024-01-01T00:00:00Z"
},
{
"address": "secondary@example.com",
"verified": false,
"primary": false,
"created_at": "2024-01-02T00:00:00Z"
}
],
"page_info": {
"has_next": false,
"next_cursor": null,
"total_count": 2
}
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().emails().list().await;
assert!(result.is_ok());
let page = result.unwrap();
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].address, "primary@example.com");
}
#[tokio::test]
async fn test_add_email() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/control/v1/users/emails"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"address": "new@example.com",
"verified": false,
"primary": false,
"created_at": "2024-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().emails().add("new@example.com").await;
assert!(result.is_ok());
let email = result.unwrap();
assert_eq!(email.address, "new@example.com");
assert!(!email.verified);
}
#[tokio::test]
async fn test_remove_email() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/control/v1/users/emails/old%40example.com"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().emails().remove("old@example.com").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_list_sessions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/users/sessions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"id": "sess_123",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0",
"created_at": "2024-01-01T00:00:00Z",
"expires_at": "2024-02-01T00:00:00Z",
"current": true
}
],
"page_info": {
"has_next": false,
"next_cursor": null,
"total_count": 1
}
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().sessions().list().await;
assert!(result.is_ok());
let page = result.unwrap();
assert_eq!(page.items.len(), 1);
assert!(page.items[0].current);
}
#[tokio::test]
async fn test_revoke_session() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/control/v1/users/sessions/sess_123"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().sessions().revoke("sess_123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_revoke_all_sessions() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/control/v1/users/sessions/revoke-all"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let result = client.account().sessions().revoke_all().await;
assert!(result.is_ok());
}
}