diff --git a/Cargo.lock b/Cargo.lock index 2f6f2b9..6ce1155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,7 +1561,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigilforge-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -1576,16 +1576,18 @@ dependencies = [ [[package]] name = "sigilforge-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", "chrono", + "directories", "keyring", "oauth2", "reqwest 0.12.24", "serde", "serde_json", + "tempfile", "thiserror 2.0.17", "tokio", "tracing", @@ -1595,7 +1597,7 @@ dependencies = [ [[package]] name = "sigilforge-daemon" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "directories", diff --git a/sigilforge-cli/src/main.rs b/sigilforge-cli/src/main.rs index 4d94bf0..16ef759 100644 --- a/sigilforge-cli/src/main.rs +++ b/sigilforge-cli/src/main.rs @@ -124,20 +124,66 @@ async fn main() -> Result<()> { } async fn add_account(service: &str, account: &str, scopes: Option<&str>) -> Result<()> { - println!("Adding account {}/{}", service, account); + use sigilforge_core::{Account, AccountId, AccountStore, ServiceId}; + + let store = AccountStore::load()?; + + let service_id = ServiceId::new(service); + let account_id = AccountId::new(account); + + let scope_list = if let Some(scopes) = scopes { + scopes.split(',').map(|s| s.trim().to_string()).collect() + } else { + Vec::new() + }; + + let new_account = Account::new(service_id.clone(), account_id.clone(), scope_list); + + store.add_account(new_account)?; + + println!("Account {}/{} added successfully", service, account); if let Some(scopes) = scopes { println!(" Scopes: {}", scopes); } - println!(" [stub] Would start OAuth flow here"); + println!(" Storage path: {:?}", store.path()); + println!(" [stub] Would start OAuth flow to obtain tokens here"); + Ok(()) } async fn list_accounts(service_filter: Option<&str>) -> Result<()> { + use sigilforge_core::{AccountStore, ServiceId}; + + let store = AccountStore::load()?; + + let filter = service_filter.map(ServiceId::new); + let accounts = store.list_accounts(filter.as_ref())?; + + if accounts.is_empty() { + println!("No accounts configured"); + if let Some(service) = service_filter { + println!(" (filtered by service: {})", service); + } + return Ok(()); + } + println!("Configured accounts:"); if let Some(service) = service_filter { println!(" (filtered by service: {})", service); } - println!(" [stub] No accounts configured yet"); + println!(); + + for account in accounts { + println!(" {}/{}", account.service, account.id); + if !account.scopes.is_empty() { + println!(" Scopes: {}", account.scopes.join(", ")); + } + println!(" Created: {}", account.created_at); + if let Some(last_used) = account.last_used { + println!(" Last used: {}", last_used); + } + } + Ok(()) } @@ -156,11 +202,67 @@ async fn get_token(service: &str, account: &str, format: &str) -> Result<()> { } async fn remove_account(service: &str, account: &str, force: bool) -> Result<()> { + use sigilforge_core::{AccountStore, CredentialType, MemoryStore, SecretStore, ServiceId, AccountId}; + use std::io::{self, Write}; + + let store = AccountStore::load()?; + let service_id = ServiceId::new(service); + let account_id = AccountId::new(account); + + // Verify account exists before prompting + let account_entry = store.get_account(&service_id, &account_id)?; + if account_entry.is_none() { + eprintln!("Error: Account {}/{} not found", service, account); + std::process::exit(1); + } + + // Prompt for confirmation unless --force is used if !force { - println!("Remove account {}/{}? [y/N]", service, account); - println!("[stub] Would prompt for confirmation"); + print!("Remove account {}/{}? [y/N] ", service, account); + io::stdout().flush()?; + + let mut response = String::new(); + io::stdin().read_line(&mut response)?; + + let confirmed = response.trim().eq_ignore_ascii_case("y") + || response.trim().eq_ignore_ascii_case("yes"); + + if !confirmed { + println!("Cancelled"); + return Ok(()); + } + } + + // Remove account from store + store.remove_account(&service_id, &account_id)?; + + // Delete associated secrets from secret store + // For now, we use MemoryStore as a placeholder since we don't have + // a global secret store instance yet. In a production implementation, + // this would use the actual secret store backend. + // + // The secret keys follow the pattern: sigilforge/{service}/{account}/{type} + let secret_store = MemoryStore::new(); + + // Common credential types to clean up + let credential_types = [ + CredentialType::AccessToken, + CredentialType::RefreshToken, + CredentialType::TokenExpiry, + CredentialType::ApiKey, + CredentialType::ClientId, + CredentialType::ClientSecret, + ]; + + for cred_type in &credential_types { + let key = format!("sigilforge/{}/{}/{}", service, account, cred_type); + // Ignore errors - the key might not exist + let _ = secret_store.delete(&key).await; } - println!("[stub] Account removed"); + + println!("Account {}/{} removed successfully", service, account); + println!(" [Note: Associated secrets have been deleted from the secret store]"); + Ok(()) } diff --git a/sigilforge-core/Cargo.toml b/sigilforge-core/Cargo.toml index a3ab5bd..5493687 100644 --- a/sigilforge-core/Cargo.toml +++ b/sigilforge-core/Cargo.toml @@ -32,6 +32,9 @@ uuid = { workspace = true } # URL parsing (for auth:// URIs) url = { workspace = true } +# Platform-specific directories +directories = { workspace = true } + # Secret storage backends (optional features) keyring = { workspace = true, optional = true } @@ -47,3 +50,4 @@ full = ["keyring-store", "oauth"] [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3.13" diff --git a/sigilforge-core/src/account_store.rs b/sigilforge-core/src/account_store.rs new file mode 100644 index 0000000..e511ce9 --- /dev/null +++ b/sigilforge-core/src/account_store.rs @@ -0,0 +1,472 @@ +//! Account metadata persistence. +//! +//! This module provides disk-backed storage for account metadata using JSON +//! serialization and platform-specific configuration directories. +//! +//! # Storage Location +//! +//! Accounts are stored at `~/.config/sigilforge/accounts.json` on Linux/macOS +//! and `%APPDATA%\sigilforge\accounts.json` on Windows. +//! +//! # Example +//! +//! ```rust,ignore +//! use sigilforge_core::account_store::AccountStore; +//! use sigilforge_core::{ServiceId, AccountId, Account}; +//! +//! let store = AccountStore::load()?; +//! let account = Account::new( +//! ServiceId::new("spotify"), +//! AccountId::new("personal"), +//! vec!["user-read-email".to_string()], +//! ); +//! store.add_account(account)?; +//! ``` + +use crate::model::{Account, AccountId, ServiceId}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use thiserror::Error; + +/// Error type for account store operations. +#[derive(Debug, Error)] +pub enum AccountStoreError { + /// Account already exists. + #[error("account {service}/{account} already exists")] + AlreadyExists { service: String, account: String }, + + /// Account not found. + #[error("account {service}/{account} not found")] + NotFound { service: String, account: String }, + + /// I/O error reading or writing the store. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// JSON serialization/deserialization error. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Configuration directory not available. + #[error("configuration directory not available")] + ConfigDirUnavailable, + + /// Internal lock poisoning error. + #[error("internal lock error: {message}")] + LockError { message: String }, +} + +/// Internal storage format for accounts. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AccountStoreData { + /// Version of the store format (for future migrations). + version: u32, + + /// All stored accounts. + accounts: Vec, +} + +impl Default for AccountStoreData { + fn default() -> Self { + Self { + version: 1, + accounts: Vec::new(), + } + } +} + +/// Disk-backed account metadata store. +/// +/// This store manages account metadata persistence using JSON files in the +/// platform-specific configuration directory. +/// +/// # Thread Safety +/// +/// This implementation uses interior mutability via `RwLock` and is safe to +/// share across threads via `Arc`. +pub struct AccountStore { + /// Path to the accounts JSON file. + path: PathBuf, + + /// In-memory cache of account data. + data: Arc>, +} + +impl AccountStore { + /// Get the default storage path for accounts. + /// + /// Returns the platform-specific configuration directory path for the + /// accounts.json file. + pub fn default_path() -> Result { + let dirs = directories::ProjectDirs::from("com", "raibid-labs", "sigilforge") + .ok_or(AccountStoreError::ConfigDirUnavailable)?; + + let config_dir = dirs.config_dir(); + Ok(config_dir.join("accounts.json")) + } + + /// Load the account store from the default location. + /// + /// Creates the file and parent directories if they don't exist. + pub fn load() -> Result { + let path = Self::default_path()?; + Self::load_from_path(path) + } + + /// Load the account store from a specific path. + /// + /// Creates the file and parent directories if they don't exist. + pub fn load_from_path(path: PathBuf) -> Result { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + // Load or create the data file + let data = if path.exists() { + let contents = fs::read_to_string(&path)?; + serde_json::from_str(&contents)? + } else { + AccountStoreData::default() + }; + + Ok(Self { + path, + data: Arc::new(RwLock::new(data)), + }) + } + + /// Save the current state to disk. + fn save(&self) -> Result<(), AccountStoreError> { + let data = self.data.read().map_err(|e| AccountStoreError::LockError { + message: format!("read lock poisoned: {}", e), + })?; + + let contents = serde_json::to_string_pretty(&*data)?; + fs::write(&self.path, contents)?; + + Ok(()) + } + + /// Add a new account to the store. + /// + /// Returns an error if an account with the same service/id already exists. + pub fn add_account(&self, account: Account) -> Result<(), AccountStoreError> { + let mut data = self.data.write().map_err(|e| AccountStoreError::LockError { + message: format!("write lock poisoned: {}", e), + })?; + + // Check for duplicates + if data + .accounts + .iter() + .any(|a| a.service == account.service && a.id == account.id) + { + return Err(AccountStoreError::AlreadyExists { + service: account.service.to_string(), + account: account.id.to_string(), + }); + } + + data.accounts.push(account); + drop(data); + + self.save() + } + + /// Get an account by service and account ID. + /// + /// Returns `Ok(None)` if the account doesn't exist. + pub fn get_account( + &self, + service: &ServiceId, + account: &AccountId, + ) -> Result, AccountStoreError> { + let data = self.data.read().map_err(|e| AccountStoreError::LockError { + message: format!("read lock poisoned: {}", e), + })?; + + Ok(data + .accounts + .iter() + .find(|a| &a.service == service && &a.id == account) + .cloned()) + } + + /// List all accounts, optionally filtered by service. + /// + /// If `service_filter` is `Some`, only accounts for that service are returned. + /// If `service_filter` is `None`, all accounts are returned. + pub fn list_accounts( + &self, + service_filter: Option<&ServiceId>, + ) -> Result, AccountStoreError> { + let data = self.data.read().map_err(|e| AccountStoreError::LockError { + message: format!("read lock poisoned: {}", e), + })?; + + let accounts = if let Some(service) = service_filter { + data.accounts + .iter() + .filter(|a| &a.service == service) + .cloned() + .collect() + } else { + data.accounts.clone() + }; + + Ok(accounts) + } + + /// Remove an account from the store. + /// + /// Returns an error if the account doesn't exist. + pub fn remove_account( + &self, + service: &ServiceId, + account: &AccountId, + ) -> Result<(), AccountStoreError> { + let mut data = self.data.write().map_err(|e| AccountStoreError::LockError { + message: format!("write lock poisoned: {}", e), + })?; + + let initial_len = data.accounts.len(); + data.accounts + .retain(|a| &a.service != service || &a.id != account); + + if data.accounts.len() == initial_len { + return Err(AccountStoreError::NotFound { + service: service.to_string(), + account: account.to_string(), + }); + } + + drop(data); + + self.save() + } + + /// Update the last_used timestamp for an account. + /// + /// This is called when a token is fetched for the account. + pub fn update_last_used( + &self, + service: &ServiceId, + account: &AccountId, + ) -> Result<(), AccountStoreError> { + let mut data = self.data.write().map_err(|e| AccountStoreError::LockError { + message: format!("write lock poisoned: {}", e), + })?; + + let account_entry = data + .accounts + .iter_mut() + .find(|a| &a.service == service && &a.id == account) + .ok_or_else(|| AccountStoreError::NotFound { + service: service.to_string(), + account: account.to_string(), + })?; + + account_entry.last_used = Some(chrono::Utc::now()); + drop(data); + + self.save() + } + + /// Get the storage path for this store. + pub fn path(&self) -> &PathBuf { + &self.path + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_account() -> Account { + Account::new( + ServiceId::new("spotify"), + AccountId::new("personal"), + vec!["user-read-email".to_string()], + ) + } + + fn test_store() -> (AccountStore, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("accounts.json"); + let store = AccountStore::load_from_path(path).unwrap(); + (store, temp_dir) + } + + #[test] + fn test_add_and_get_account() { + let (store, _temp) = test_store(); + let account = test_account(); + + store.add_account(account.clone()).unwrap(); + + let retrieved = store + .get_account(&account.service, &account.id) + .unwrap() + .unwrap(); + + assert_eq!(retrieved.service, account.service); + assert_eq!(retrieved.id, account.id); + assert_eq!(retrieved.scopes, account.scopes); + } + + #[test] + fn test_add_duplicate_account() { + let (store, _temp) = test_store(); + let account = test_account(); + + store.add_account(account.clone()).unwrap(); + let result = store.add_account(account); + + assert!(matches!( + result, + Err(AccountStoreError::AlreadyExists { .. }) + )); + } + + #[test] + fn test_list_all_accounts() { + let (store, _temp) = test_store(); + + let account1 = Account::new( + ServiceId::new("spotify"), + AccountId::new("personal"), + vec![], + ); + let account2 = Account::new( + ServiceId::new("spotify"), + AccountId::new("work"), + vec![], + ); + let account3 = Account::new( + ServiceId::new("github"), + AccountId::new("main"), + vec![], + ); + + store.add_account(account1).unwrap(); + store.add_account(account2).unwrap(); + store.add_account(account3).unwrap(); + + let all_accounts = store.list_accounts(None).unwrap(); + assert_eq!(all_accounts.len(), 3); + } + + #[test] + fn test_list_accounts_filtered() { + let (store, _temp) = test_store(); + + let account1 = Account::new( + ServiceId::new("spotify"), + AccountId::new("personal"), + vec![], + ); + let account2 = Account::new( + ServiceId::new("spotify"), + AccountId::new("work"), + vec![], + ); + let account3 = Account::new( + ServiceId::new("github"), + AccountId::new("main"), + vec![], + ); + + store.add_account(account1).unwrap(); + store.add_account(account2).unwrap(); + store.add_account(account3).unwrap(); + + let spotify_accounts = store + .list_accounts(Some(&ServiceId::new("spotify"))) + .unwrap(); + assert_eq!(spotify_accounts.len(), 2); + + let github_accounts = store + .list_accounts(Some(&ServiceId::new("github"))) + .unwrap(); + assert_eq!(github_accounts.len(), 1); + } + + #[test] + fn test_remove_account() { + let (store, _temp) = test_store(); + let account = test_account(); + + store.add_account(account.clone()).unwrap(); + store + .remove_account(&account.service, &account.id) + .unwrap(); + + let retrieved = store.get_account(&account.service, &account.id).unwrap(); + assert!(retrieved.is_none()); + } + + #[test] + fn test_remove_nonexistent_account() { + let (store, _temp) = test_store(); + + let result = store.remove_account( + &ServiceId::new("spotify"), + &AccountId::new("nonexistent"), + ); + + assert!(matches!(result, Err(AccountStoreError::NotFound { .. }))); + } + + #[test] + fn test_persistence() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("accounts.json"); + + // Create store and add account + { + let store = AccountStore::load_from_path(path.clone()).unwrap(); + let account = test_account(); + store.add_account(account).unwrap(); + } + + // Load store again and verify account persisted + { + let store = AccountStore::load_from_path(path).unwrap(); + let accounts = store.list_accounts(None).unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].service.as_str(), "spotify"); + assert_eq!(accounts[0].id.as_str(), "personal"); + } + } + + #[test] + fn test_update_last_used() { + let (store, _temp) = test_store(); + let account = test_account(); + + store.add_account(account.clone()).unwrap(); + + // Initially last_used should be None + let retrieved = store + .get_account(&account.service, &account.id) + .unwrap() + .unwrap(); + assert!(retrieved.last_used.is_none()); + + // Update last_used + store + .update_last_used(&account.service, &account.id) + .unwrap(); + + // Verify last_used is now set + let retrieved = store + .get_account(&account.service, &account.id) + .unwrap() + .unwrap(); + assert!(retrieved.last_used.is_some()); + } +} diff --git a/sigilforge-core/src/lib.rs b/sigilforge-core/src/lib.rs index 28fa09d..6cae97e 100644 --- a/sigilforge-core/src/lib.rs +++ b/sigilforge-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod store; pub mod token; pub mod resolve; pub mod error; +pub mod account_store; // Re-export commonly used types at crate root pub use model::{ @@ -40,8 +41,12 @@ pub use store::{ SecretStore, StoreError, MemoryStore, + create_store, }; +#[cfg(feature = "keyring-store")] +pub use store::KeyringStore; + pub use token::{ Token, TokenSet, @@ -57,3 +62,8 @@ pub use resolve::{ }; pub use error::SigilforgeError; + +pub use account_store::{ + AccountStore, + AccountStoreError, +}; diff --git a/sigilforge-core/tests/account_lifecycle.rs b/sigilforge-core/tests/account_lifecycle.rs new file mode 100644 index 0000000..d8b0668 --- /dev/null +++ b/sigilforge-core/tests/account_lifecycle.rs @@ -0,0 +1,361 @@ +//! Integration tests for account lifecycle operations. +//! +//! These tests verify the end-to-end functionality of account management: +//! - Adding accounts +//! - Listing accounts +//! - Retrieving specific accounts +//! - Removing accounts +//! - Error handling for edge cases + +use sigilforge_core::{Account, AccountId, AccountStore, AccountStoreError, ServiceId}; +use tempfile::TempDir; + +/// Helper to create a test store in a temporary directory. +fn test_store() -> (AccountStore, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("accounts.json"); + let store = AccountStore::load_from_path(path).unwrap(); + (store, temp_dir) +} + +/// Helper to create a test account. +fn test_account(service: &str, account: &str, scopes: Vec<&str>) -> Account { + Account::new( + ServiceId::new(service), + AccountId::new(account), + scopes.into_iter().map(String::from).collect(), + ) +} + +#[test] +fn test_add_account_happy_path() { + let (store, _temp) = test_store(); + + let account = test_account("spotify", "personal", vec!["user-read-email"]); + let result = store.add_account(account); + + assert!(result.is_ok(), "Should successfully add account"); + + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap(); + + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.service.as_str(), "spotify"); + assert_eq!(retrieved.id.as_str(), "personal"); + assert_eq!(retrieved.scopes, vec!["user-read-email"]); +} + +#[test] +fn test_add_duplicate_account_fails() { + let (store, _temp) = test_store(); + + let account1 = test_account("spotify", "personal", vec![]); + let account2 = test_account("spotify", "personal", vec!["different-scope"]); + + store.add_account(account1).unwrap(); + let result = store.add_account(account2); + + assert!(result.is_err(), "Should fail when adding duplicate account"); + assert!( + matches!(result, Err(AccountStoreError::AlreadyExists { .. })), + "Error should be AlreadyExists" + ); +} + +#[test] +fn test_list_all_accounts() { + let (store, _temp) = test_store(); + + let account1 = test_account("spotify", "personal", vec![]); + let account2 = test_account("spotify", "work", vec![]); + let account3 = test_account("github", "main", vec![]); + + store.add_account(account1).unwrap(); + store.add_account(account2).unwrap(); + store.add_account(account3).unwrap(); + + let all_accounts = store.list_accounts(None).unwrap(); + + assert_eq!(all_accounts.len(), 3, "Should return all 3 accounts"); +} + +#[test] +fn test_list_accounts_filtered_by_service() { + let (store, _temp) = test_store(); + + let account1 = test_account("spotify", "personal", vec![]); + let account2 = test_account("spotify", "work", vec![]); + let account3 = test_account("github", "main", vec![]); + + store.add_account(account1).unwrap(); + store.add_account(account2).unwrap(); + store.add_account(account3).unwrap(); + + let spotify_accounts = store + .list_accounts(Some(&ServiceId::new("spotify"))) + .unwrap(); + + assert_eq!( + spotify_accounts.len(), + 2, + "Should return 2 spotify accounts" + ); + assert!(spotify_accounts + .iter() + .all(|a| a.service.as_str() == "spotify")); + + let github_accounts = store + .list_accounts(Some(&ServiceId::new("github"))) + .unwrap(); + + assert_eq!(github_accounts.len(), 1, "Should return 1 github account"); +} + +#[test] +fn test_list_accounts_empty() { + let (store, _temp) = test_store(); + + let accounts = store.list_accounts(None).unwrap(); + + assert_eq!(accounts.len(), 0, "Should return empty list"); +} + +#[test] +fn test_get_account_exists() { + let (store, _temp) = test_store(); + + let account = test_account("spotify", "personal", vec!["user-read-email"]); + store.add_account(account).unwrap(); + + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap(); + + assert!(retrieved.is_some(), "Account should exist"); +} + +#[test] +fn test_get_account_not_found() { + let (store, _temp) = test_store(); + + let retrieved = store + .get_account( + &ServiceId::new("nonexistent"), + &AccountId::new("account"), + ) + .unwrap(); + + assert!(retrieved.is_none(), "Account should not exist"); +} + +#[test] +fn test_remove_account_happy_path() { + let (store, _temp) = test_store(); + + let account = test_account("spotify", "personal", vec![]); + store.add_account(account).unwrap(); + + let result = store.remove_account(&ServiceId::new("spotify"), &AccountId::new("personal")); + + assert!(result.is_ok(), "Should successfully remove account"); + + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap(); + + assert!(retrieved.is_none(), "Account should be removed"); +} + +#[test] +fn test_remove_nonexistent_account_fails() { + let (store, _temp) = test_store(); + + let result = store.remove_account( + &ServiceId::new("spotify"), + &AccountId::new("nonexistent"), + ); + + assert!(result.is_err(), "Should fail when removing nonexistent account"); + assert!( + matches!(result, Err(AccountStoreError::NotFound { .. })), + "Error should be NotFound" + ); +} + +#[test] +fn test_account_persistence() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("accounts.json"); + + // Create store and add accounts + { + let store = AccountStore::load_from_path(path.clone()).unwrap(); + let account1 = test_account("spotify", "personal", vec!["scope1"]); + let account2 = test_account("github", "work", vec!["scope2", "scope3"]); + + store.add_account(account1).unwrap(); + store.add_account(account2).unwrap(); + } + + // Load store again and verify persistence + { + let store = AccountStore::load_from_path(path).unwrap(); + let accounts = store.list_accounts(None).unwrap(); + + assert_eq!(accounts.len(), 2, "Should persist 2 accounts"); + + let spotify_account = accounts + .iter() + .find(|a| a.service.as_str() == "spotify") + .unwrap(); + assert_eq!(spotify_account.id.as_str(), "personal"); + assert_eq!(spotify_account.scopes, vec!["scope1"]); + + let github_account = accounts + .iter() + .find(|a| a.service.as_str() == "github") + .unwrap(); + assert_eq!(github_account.id.as_str(), "work"); + assert_eq!(github_account.scopes, vec!["scope2", "scope3"]); + } +} + +#[test] +fn test_update_last_used() { + let (store, _temp) = test_store(); + + let account = test_account("spotify", "personal", vec![]); + store.add_account(account).unwrap(); + + // Initially last_used should be None + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap() + .unwrap(); + assert!(retrieved.last_used.is_none()); + + // Update last_used + store + .update_last_used(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap(); + + // Verify last_used is now set + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap() + .unwrap(); + assert!(retrieved.last_used.is_some()); +} + +#[test] +fn test_update_last_used_nonexistent_account() { + let (store, _temp) = test_store(); + + let result = store.update_last_used( + &ServiceId::new("spotify"), + &AccountId::new("nonexistent"), + ); + + assert!(result.is_err(), "Should fail for nonexistent account"); + assert!( + matches!(result, Err(AccountStoreError::NotFound { .. })), + "Error should be NotFound" + ); +} + +#[test] +fn test_multiple_accounts_same_service() { + let (store, _temp) = test_store(); + + let personal = test_account("spotify", "personal", vec!["scope1"]); + let work = test_account("spotify", "work", vec!["scope2"]); + let family = test_account("spotify", "family", vec!["scope3"]); + + store.add_account(personal).unwrap(); + store.add_account(work).unwrap(); + store.add_account(family).unwrap(); + + let accounts = store + .list_accounts(Some(&ServiceId::new("spotify"))) + .unwrap(); + + assert_eq!(accounts.len(), 3, "Should support multiple accounts per service"); + + let account_ids: Vec<_> = accounts.iter().map(|a| a.id.as_str()).collect(); + assert!(account_ids.contains(&"personal")); + assert!(account_ids.contains(&"work")); + assert!(account_ids.contains(&"family")); +} + +#[test] +fn test_account_key_generation() { + let account = test_account("spotify", "personal", vec![]); + let key = account.key(); + + assert_eq!(key, "spotify/personal", "Should generate correct key format"); +} + +#[test] +fn test_service_id_normalization() { + let (store, _temp) = test_store(); + + // Add account with uppercase service name + let account = Account::new( + ServiceId::new("SPOTIFY"), + AccountId::new("personal"), + vec![], + ); + store.add_account(account).unwrap(); + + // Retrieve with lowercase + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap(); + + assert!( + retrieved.is_some(), + "Service IDs should be normalized to lowercase" + ); +} + +#[test] +fn test_empty_scopes() { + let (store, _temp) = test_store(); + + let account = test_account("github", "main", vec![]); + store.add_account(account).unwrap(); + + let retrieved = store + .get_account(&ServiceId::new("github"), &AccountId::new("main")) + .unwrap() + .unwrap(); + + assert_eq!( + retrieved.scopes.len(), + 0, + "Should support accounts with no scopes" + ); +} + +#[test] +fn test_complex_scopes() { + let (store, _temp) = test_store(); + + let scopes = vec![ + "user-read-email", + "user-read-private", + "playlist-modify-public", + "playlist-modify-private", + ]; + let account = test_account("spotify", "personal", scopes.clone()); + store.add_account(account).unwrap(); + + let retrieved = store + .get_account(&ServiceId::new("spotify"), &AccountId::new("personal")) + .unwrap() + .unwrap(); + + assert_eq!(retrieved.scopes, scopes, "Should preserve all scopes"); +}