diff --git a/veza-common/Cargo.toml b/veza-common/Cargo.toml new file mode 100644 index 000000000..dde0e1adf --- /dev/null +++ b/veza-common/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "veza-common" +version = "0.1.0" +edition = "2021" +authors = ["Veza Team "] +description = "Common library for Veza project - shared types and utilities" +license = "MIT" +repository = "https://github.com/okinrev/veza-full-stack" + +[dependencies] +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# UUID support +uuid = { version = "1.0", features = ["v4", "serde"] } + +# Date/time handling +chrono = { version = "0.4", features = ["serde"] } + +# Error handling +thiserror = "1.0" + +# Regex support +regex = "1.0" + +# Lazy static for compiled regex +lazy_static = "1.4" + +[dev-dependencies] +# Testing +tokio = { version = "1.0", features = ["full"] } + diff --git a/veza-common/README.md b/veza-common/README.md new file mode 100644 index 000000000..1478a3bec --- /dev/null +++ b/veza-common/README.md @@ -0,0 +1,372 @@ +# Veza Common Library + +Bibliothèque commune pour tous les services Veza. Cette bibliothèque fournit des types partagés, des utilitaires et des configurations communes utilisées par tous les services du projet Veza. + +## Installation + +Ajoutez cette dépendance à votre `Cargo.toml`: + +```toml +[dependencies] +veza-common = { path = "../veza-common" } +``` + +## Modules + +### Types (`types`) + +Types de données partagés pour tous les services. + +#### User + +```rust +use veza_common::types::User; + +let user = User::new(1, "john_doe".to_string(), "john@example.com".to_string()); +assert!(user.validate().is_ok()); +``` + +#### Track + +```rust +use veza_common::types::Track; +use uuid::Uuid; + +let track_id = Uuid::new_v4(); +let track = Track::new( + track_id, + "Song Title".to_string(), + "Artist Name".to_string(), + 180 // duration in seconds +); +assert_eq!(track.formatted_duration(), "3:00"); +``` + +#### Playlist + +```rust +use veza_common::types::Playlist; +use uuid::Uuid; + +let playlist_id = Uuid::new_v4(); +let owner_id = Uuid::new_v4(); +let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + +let track_id = Uuid::new_v4(); +playlist.add_track(track_id); +assert_eq!(playlist.track_count(), 1); +``` + +#### API Response + +```rust +use veza_common::types::ApiResponse; + +// Success response +let response = ApiResponse::success("data".to_string()); +assert!(response.success); + +// Error response +let error_response = ApiResponse::::error("Error message".to_string()); +assert!(!error_response.success); +``` + +### Error Handling (`error`) + +Types d'erreurs standardisés avec codes HTTP et messages. + +```rust +use veza_common::error::{CommonError, CommonResult}; + +fn example_function() -> CommonResult { + // Return success + Ok(42) + + // Or error + // Err(CommonError::NotFound("Resource not found".to_string())) +} + +// Error codes +let error = CommonError::ValidationError("Invalid input".to_string()); +assert_eq!(error.code(), "VALIDATION_ERROR"); +assert_eq!(error.http_status_code(), 400); +``` + +### Configuration (`config`) + +Types de configuration pour Database et Redis. + +#### DatabaseConfig + +```rust +use veza_common::config::DatabaseConfig; +use std::time::Duration; + +let config = DatabaseConfig::new( + "postgresql://user:password@localhost:5432/mydb".to_string(), + 10 // max_connections +); + +// Validate configuration +assert!(config.validate().is_ok()); + +// Extract information +let host = config.host(); // Some("localhost") +let port = config.port(); // Some(5432) +let db_name = config.database_name(); // Some("mydb") +``` + +#### RedisConfig + +```rust +use veza_common::config::RedisConfig; + +let config = RedisConfig::new("redis://localhost:6379".to_string()); + +// Validate configuration +assert!(config.validate().is_ok()); + +// Check SSL +let is_ssl = config.is_ssl(); // false for redis://, true for rediss:// + +// Extract information +let host = config.host(); // Some("localhost") +let port = config.port(); // Some(6379) +``` + +### Utilities (`utils`) + +#### Validation + +```rust +use veza_common::utils::validation::*; + +// Email validation +assert!(validate_email("test@example.com")); +assert!(!validate_email("invalid-email")); + +// Username validation +assert!(validate_username("user123")); +assert!(!validate_username("ab")); // Too short + +// Password validation +assert!(validate_password("Password123")); +assert!(!validate_password("short")); // Too short + +// With Result return +let result = validate_email_result("test@example.com"); +assert!(result.is_ok()); +``` + +#### Serialization + +```rust +use veza_common::utils::serialization::*; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct MyStruct { + name: String, + value: i32, +} + +let data = MyStruct { + name: "test".to_string(), + value: 42, +}; + +// Serialize to JSON +let json = to_json(&data).unwrap(); + +// Deserialize from JSON +let deserialized: MyStruct = from_json(&json).unwrap(); +assert_eq!(data, deserialized); + +// Pretty print +let pretty = to_json_pretty(&data).unwrap(); +``` + +#### Date Utilities + +```rust +use veza_common::utils::date::*; +use chrono::{DateTime, Utc, Duration}; + +// Format timestamp +let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC +let formatted = format_timestamp(timestamp); + +// Parse date +let date_str = "2021-01-01T00:00:00Z"; +let dt: DateTime = parse_date(date_str).unwrap(); + +// Relative time +let now = Utc::now(); +let past = now - Duration::hours(2); +let relative = format_relative_time(&past, &now); // "2 hours ago" + +// Current timestamp +let ts = current_timestamp(); +``` + +#### Logging + +```rust +use veza_common::utils::logging::*; +use std::collections::HashMap; + +// Simple logging +log_info("api", "Service started"); +log_error("api", "Connection failed", None); + +// Request logging +log_request("api", "GET", "/users"); + +// Request with context +let mut context = HashMap::new(); +context.insert("user_id".to_string(), "123".to_string()); +log_request_with_context("api", "POST", "/users", &context); + +// Structured logging +let entry = StructuredLogEntry::new("api", "INFO", "User created") + .with_context("user_id".to_string(), "123".to_string()); +let json = entry.to_json(); +``` + +#### General Utilities + +```rust +use veza_common::utils::*; + +// Generate UUID +let uuid = generate_uuid(); + +// Format duration +let formatted = format_duration(125); // "2:05" + +// Format file size +let size = format_file_size(1024); // "1.00 KB" +``` + +## Exemples d'Usage Complets + +### Exemple 1: Créer et valider un utilisateur + +```rust +use veza_common::types::User; +use veza_common::utils::validation::validate_email_result; + +fn create_user(username: String, email: String) -> Result { + // Validate email + validate_email_result(&email)?; + + // Create user + let user = User::new(1, username, email); + + // Validate user data + user.validate()?; + + Ok(user) +} +``` + +### Exemple 2: Configuration de base de données + +```rust +use veza_common::config::DatabaseConfig; +use veza_common::error::CommonResult; + +fn setup_database() -> CommonResult { + let config = DatabaseConfig::new( + std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://localhost/mydb".to_string()), + 20 + ); + + // Validate configuration + config.validate()?; + + Ok(config) +} +``` + +### Exemple 3: API Response avec pagination + +```rust +use veza_common::types::{ApiResponse, PaginatedResponse}; + +fn get_users(page: u32, limit: u32) -> ApiResponse> { + let items = vec![/* ... */]; + let total = 100; + + let paginated = PaginatedResponse::new(items, total, page, limit); + ApiResponse::success(paginated) +} +``` + +### Exemple 4: Gestion d'erreurs + +```rust +use veza_common::error::{CommonError, CommonResult}; + +fn process_request(data: &str) -> CommonResult { + if data.is_empty() { + return Err(CommonError::ValidationError( + "Data cannot be empty".to_string() + )); + } + + // Process data... + Ok("processed".to_string()) +} +``` + +## Tests + +La bibliothèque inclut une infrastructure de tests complète dans `tests/common_tests.rs` avec: + +- **Fixtures**: Fonctions pour créer des données de test +- **Helpers**: Fonctions utilitaires pour les tests +- **Exemples**: Tests d'intégration complets + +Exécuter les tests: + +```bash +cargo test +``` + +## Documentation API + +Pour générer la documentation complète: + +```bash +cargo doc --open +``` + +## Structure du Projet + +``` +veza-common/ +├── src/ +│ ├── lib.rs # Module principal +│ ├── types/ # Types partagés (User, Track, Playlist) +│ ├── error.rs # Gestion d'erreurs +│ ├── config/ # Configurations (Database, Redis) +│ └── utils/ # Utilitaires +│ ├── validation.rs +│ ├── serialization.rs +│ ├── date.rs +│ └── logging.rs +├── tests/ +│ └── common_tests.rs # Tests d'intégration +└── Cargo.toml +``` + +## Licence + +MIT + +## Contribution + +Les contributions sont les bienvenues ! Veuillez suivre les standards de code définis dans `ORIGIN_CODE_STANDARDS.md`. + diff --git a/veza-common/src/config/database.rs b/veza-common/src/config/database.rs new file mode 100644 index 000000000..8151987ba --- /dev/null +++ b/veza-common/src/config/database.rs @@ -0,0 +1,306 @@ +//! Database configuration types +//! +//! This module defines configuration structures for database connections. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use crate::error::{CommonError, CommonResult}; + +/// Database configuration +/// +/// Configuration for database connections including connection pooling settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DatabaseConfig { + /// Database connection URL + pub url: String, + /// Maximum number of connections in the pool + pub max_connections: u32, + /// Minimum number of connections in the pool + #[serde(default = "default_min_connections")] + pub min_connections: u32, + /// Connection timeout + #[serde(with = "duration_secs", default = "default_connection_timeout")] + pub connection_timeout: Duration, + /// Idle timeout before closing connections + #[serde(with = "duration_secs", default = "default_idle_timeout")] + pub idle_timeout: Duration, + /// Maximum lifetime of a connection + #[serde(with = "duration_secs", default = "default_max_lifetime")] + pub max_lifetime: Duration, + /// Enable SQL query logging + #[serde(default = "default_false")] + pub enable_logging: bool, + /// Run migrations on startup + #[serde(default = "default_false")] + pub migrate_on_start: bool, +} + +fn default_min_connections() -> u32 { + 1 +} + +fn default_connection_timeout() -> Duration { + Duration::from_secs(30) +} + +fn default_idle_timeout() -> Duration { + Duration::from_secs(600) +} + +fn default_max_lifetime() -> Duration { + Duration::from_secs(3600) +} + +fn default_false() -> bool { + false +} + +/// Serialize/Deserialize Duration as seconds +mod duration_secs { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u64(duration.as_secs()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let secs = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(secs)) + } +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + url: "postgresql://user:password@localhost/database".to_string(), + max_connections: 10, + min_connections: 1, + connection_timeout: Duration::from_secs(30), + idle_timeout: Duration::from_secs(600), + max_lifetime: Duration::from_secs(3600), + enable_logging: false, + migrate_on_start: false, + } + } +} + +impl DatabaseConfig { + /// Create a new database configuration + pub fn new(url: String, max_connections: u32) -> Self { + Self { + url, + max_connections, + min_connections: 1, + connection_timeout: Duration::from_secs(30), + idle_timeout: Duration::from_secs(600), + max_lifetime: Duration::from_secs(3600), + enable_logging: false, + migrate_on_start: false, + } + } + + /// Validate the database configuration + pub fn validate(&self) -> CommonResult<()> { + if self.url.is_empty() { + return Err(CommonError::ValidationError( + "Database URL cannot be empty".to_string() + )); + } + + if !self.url.starts_with("postgresql://") && !self.url.starts_with("postgres://") { + return Err(CommonError::ValidationError( + "Database URL must start with postgresql:// or postgres://".to_string() + )); + } + + if self.max_connections == 0 { + return Err(CommonError::ValidationError( + "Max connections must be greater than 0".to_string() + )); + } + + if self.min_connections > self.max_connections { + return Err(CommonError::ValidationError( + "Min connections cannot be greater than max connections".to_string() + )); + } + + if self.connection_timeout.as_secs() == 0 { + return Err(CommonError::ValidationError( + "Connection timeout must be greater than 0".to_string() + )); + } + + Ok(()) + } + + /// Get the database name from the URL + pub fn database_name(&self) -> Option { + self.url + .split('/') + .last() + .and_then(|s| s.split('?').next()) + .map(|s| s.to_string()) + } + + /// Get the host from the URL + pub fn host(&self) -> Option { + self.url + .strip_prefix("postgresql://") + .or_else(|| self.url.strip_prefix("postgres://")) + .and_then(|s| { + // Remove user:password@ if present + let s = if s.contains('@') { + s.split('@').nth(1).unwrap_or(s) + } else { + s + }; + // Extract host:port + s.split('/').next().map(|s| { + if s.contains(':') { + s.split(':').next().unwrap_or(s).to_string() + } else { + s.to_string() + } + }) + }) + } + + /// Get the port from the URL + pub fn port(&self) -> Option { + self.url + .strip_prefix("postgresql://") + .or_else(|| self.url.strip_prefix("postgres://")) + .and_then(|s| { + let s = if s.contains('@') { + s.split('@').nth(1).unwrap_or(s) + } else { + s + }; + s.split('/').next().and_then(|s| { + if s.contains(':') { + s.split(':').nth(1).and_then(|p| p.parse().ok()) + } else { + None + } + }) + }) + .or(Some(5432)) // Default PostgreSQL port + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_database_config_default() { + let config = DatabaseConfig::default(); + assert!(!config.url.is_empty()); + assert_eq!(config.max_connections, 10); + assert_eq!(config.min_connections, 1); + } + + #[test] + fn test_database_config_new() { + let config = DatabaseConfig::new("postgresql://localhost/test".to_string(), 20); + assert_eq!(config.url, "postgresql://localhost/test"); + assert_eq!(config.max_connections, 20); + assert_eq!(config.min_connections, 1); + } + + #[test] + fn test_database_config_validate_success() { + let config = DatabaseConfig::new("postgresql://user:pass@localhost/db".to_string(), 10); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_database_config_validate_empty_url() { + let mut config = DatabaseConfig::default(); + config.url = "".to_string(); + assert!(config.validate().is_err()); + } + + #[test] + fn test_database_config_validate_invalid_url() { + let config = DatabaseConfig::new("mysql://localhost/db".to_string(), 10); + assert!(config.validate().is_err()); + } + + #[test] + fn test_database_config_validate_zero_max_connections() { + let config = DatabaseConfig::new("postgresql://localhost/db".to_string(), 0); + assert!(config.validate().is_err()); + } + + #[test] + fn test_database_config_validate_min_greater_than_max() { + let mut config = DatabaseConfig::new("postgresql://localhost/db".to_string(), 10); + config.min_connections = 20; + assert!(config.validate().is_err()); + } + + #[test] + fn test_database_config_database_name() { + let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10); + assert_eq!(config.database_name(), Some("mydb".to_string())); + } + + #[test] + fn test_database_config_database_name_with_params() { + let config = DatabaseConfig::new("postgresql://localhost/mydb?sslmode=require".to_string(), 10); + assert_eq!(config.database_name(), Some("mydb".to_string())); + } + + #[test] + fn test_database_config_host() { + let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10); + assert_eq!(config.host(), Some("localhost".to_string())); + } + + #[test] + fn test_database_config_host_with_auth() { + let config = DatabaseConfig::new("postgresql://user:pass@localhost/mydb".to_string(), 10); + assert_eq!(config.host(), Some("localhost".to_string())); + } + + #[test] + fn test_database_config_port() { + let config = DatabaseConfig::new("postgresql://localhost:5433/mydb".to_string(), 10); + assert_eq!(config.port(), Some(5433)); + } + + #[test] + fn test_database_config_port_default() { + let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10); + assert_eq!(config.port(), Some(5432)); + } + + #[test] + fn test_database_config_serialize() { + let config = DatabaseConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("url")); + assert!(json.contains("max_connections")); + } + + #[test] + fn test_database_config_deserialize() { + let json = r#"{ + "url": "postgresql://localhost/test", + "max_connections": 20 + }"#; + let config: DatabaseConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.url, "postgresql://localhost/test"); + assert_eq!(config.max_connections, 20); + } +} + diff --git a/veza-common/src/config/mod.rs b/veza-common/src/config/mod.rs new file mode 100644 index 000000000..4b75b3aae --- /dev/null +++ b/veza-common/src/config/mod.rs @@ -0,0 +1,10 @@ +//! Configuration types module +//! +//! This module provides shared configuration types for Veza services. + +pub mod database; +pub mod redis; + +pub use database::DatabaseConfig; +pub use redis::RedisConfig; + diff --git a/veza-common/src/config/redis.rs b/veza-common/src/config/redis.rs new file mode 100644 index 000000000..a966dd788 --- /dev/null +++ b/veza-common/src/config/redis.rs @@ -0,0 +1,312 @@ +//! Redis configuration types +//! +//! This module defines configuration structures for Redis connections. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use crate::error::{CommonError, CommonResult}; + +/// Redis configuration +/// +/// Configuration for Redis connections including connection pooling settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RedisConfig { + /// Redis connection URL + pub url: String, + /// Maximum number of connections in the pool + #[serde(default = "default_max_connections")] + pub max_connections: u32, + /// Connection timeout + #[serde(with = "duration_secs", default = "default_connection_timeout")] + pub connection_timeout: Duration, + /// Command timeout + #[serde(with = "duration_secs", default = "default_command_timeout")] + pub command_timeout: Duration, + /// Enable connection keepalive + #[serde(default = "default_true")] + pub keepalive: bool, + /// Database number (0-15) + #[serde(default = "default_db")] + pub db: u8, + /// Password for authentication (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Username for authentication (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +fn default_max_connections() -> u32 { + 10 +} + +fn default_connection_timeout() -> Duration { + Duration::from_secs(5) +} + +fn default_command_timeout() -> Duration { + Duration::from_secs(3) +} + +fn default_true() -> bool { + true +} + +fn default_db() -> u8 { + 0 +} + +/// Serialize/Deserialize Duration as seconds +mod duration_secs { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u64(duration.as_secs()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let secs = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(secs)) + } +} + +impl Default for RedisConfig { + fn default() -> Self { + Self { + url: "redis://localhost:6379".to_string(), + max_connections: 10, + connection_timeout: Duration::from_secs(5), + command_timeout: Duration::from_secs(3), + keepalive: true, + db: 0, + password: None, + username: None, + } + } +} + +impl RedisConfig { + /// Create a new Redis configuration + pub fn new(url: String) -> Self { + Self { + url, + max_connections: 10, + connection_timeout: Duration::from_secs(5), + command_timeout: Duration::from_secs(3), + keepalive: true, + db: 0, + password: None, + username: None, + } + } + + /// Validate the Redis configuration + pub fn validate(&self) -> CommonResult<()> { + if self.url.is_empty() { + return Err(CommonError::ValidationError( + "Redis URL cannot be empty".to_string() + )); + } + + if !self.url.starts_with("redis://") && !self.url.starts_with("rediss://") { + return Err(CommonError::ValidationError( + "Redis URL must start with redis:// or rediss://".to_string() + )); + } + + if self.max_connections == 0 { + return Err(CommonError::ValidationError( + "Max connections must be greater than 0".to_string() + )); + } + + if self.db > 15 { + return Err(CommonError::ValidationError( + "Database number must be between 0 and 15".to_string() + )); + } + + if self.connection_timeout.as_secs() == 0 { + return Err(CommonError::ValidationError( + "Connection timeout must be greater than 0".to_string() + )); + } + + Ok(()) + } + + /// Get the host from the URL + pub fn host(&self) -> Option { + self.url + .strip_prefix("redis://") + .or_else(|| self.url.strip_prefix("rediss://")) + .and_then(|s| { + // Remove user:password@ if present + let s = if s.contains('@') { + s.split('@').nth(1).unwrap_or(s) + } else { + s + }; + // Extract host:port + s.split('/').next().map(|s| { + if s.contains(':') { + s.split(':').next().unwrap_or(s).to_string() + } else { + s.to_string() + } + }) + }) + } + + /// Get the port from the URL + pub fn port(&self) -> Option { + self.url + .strip_prefix("redis://") + .or_else(|| self.url.strip_prefix("rediss://")) + .and_then(|s| { + let s = if s.contains('@') { + s.split('@').nth(1).unwrap_or(s) + } else { + s + }; + s.split('/').next().and_then(|s| { + if s.contains(':') { + s.split(':').nth(1).and_then(|p| p.parse().ok()) + } else { + None + } + }) + }) + .or(Some(6379)) // Default Redis port + } + + /// Check if SSL/TLS is enabled + pub fn is_ssl(&self) -> bool { + self.url.starts_with("rediss://") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_redis_config_default() { + let config = RedisConfig::default(); + assert!(!config.url.is_empty()); + assert_eq!(config.max_connections, 10); + assert_eq!(config.db, 0); + assert!(config.keepalive); + } + + #[test] + fn test_redis_config_new() { + let config = RedisConfig::new("redis://localhost:6379".to_string()); + assert_eq!(config.url, "redis://localhost:6379"); + assert_eq!(config.max_connections, 10); + assert_eq!(config.db, 0); + } + + #[test] + fn test_redis_config_validate_success() { + let config = RedisConfig::new("redis://localhost:6379".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_redis_config_validate_empty_url() { + let mut config = RedisConfig::default(); + config.url = "".to_string(); + assert!(config.validate().is_err()); + } + + #[test] + fn test_redis_config_validate_invalid_url() { + let config = RedisConfig::new("http://localhost:6379".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_redis_config_validate_zero_max_connections() { + let mut config = RedisConfig::new("redis://localhost:6379".to_string()); + config.max_connections = 0; + assert!(config.validate().is_err()); + } + + #[test] + fn test_redis_config_validate_db_too_large() { + let mut config = RedisConfig::new("redis://localhost:6379".to_string()); + config.db = 16; + assert!(config.validate().is_err()); + } + + #[test] + fn test_redis_config_host() { + let config = RedisConfig::new("redis://localhost:6379".to_string()); + assert_eq!(config.host(), Some("localhost".to_string())); + } + + #[test] + fn test_redis_config_host_with_auth() { + let config = RedisConfig::new("redis://user:pass@localhost:6379".to_string()); + assert_eq!(config.host(), Some("localhost".to_string())); + } + + #[test] + fn test_redis_config_port() { + let config = RedisConfig::new("redis://localhost:6380".to_string()); + assert_eq!(config.port(), Some(6380)); + } + + #[test] + fn test_redis_config_port_default() { + let config = RedisConfig::new("redis://localhost".to_string()); + assert_eq!(config.port(), Some(6379)); + } + + #[test] + fn test_redis_config_is_ssl() { + let config_ssl = RedisConfig::new("rediss://localhost:6379".to_string()); + assert!(config_ssl.is_ssl()); + + let config_no_ssl = RedisConfig::new("redis://localhost:6379".to_string()); + assert!(!config_no_ssl.is_ssl()); + } + + #[test] + fn test_redis_config_serialize() { + let config = RedisConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("url")); + assert!(json.contains("max_connections")); + assert!(!json.contains("password")); // Should be skipped if None + } + + #[test] + fn test_redis_config_deserialize() { + let json = r#"{ + "url": "redis://localhost:6379", + "max_connections": 20, + "db": 1 + }"#; + let config: RedisConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.url, "redis://localhost:6379"); + assert_eq!(config.max_connections, 20); + assert_eq!(config.db, 1); + } + + #[test] + fn test_redis_config_with_password() { + let mut config = RedisConfig::new("redis://localhost:6379".to_string()); + config.password = Some("secret".to_string()); + assert_eq!(config.password, Some("secret".to_string())); + } +} + diff --git a/veza-common/src/error.rs b/veza-common/src/error.rs new file mode 100644 index 000000000..a91181a40 --- /dev/null +++ b/veza-common/src/error.rs @@ -0,0 +1,275 @@ +//! Common error types for Veza services +//! +//! This module provides standardized error handling across all Veza services. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Common error type for Veza services +#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "message")] +pub enum CommonError { + /// Resource not found + #[error("Not found: {0}")] + NotFound(String), + + /// Validation error + #[error("Validation error: {0}")] + ValidationError(String), + + /// Internal server error + #[error("Internal error: {0}")] + InternalError(String), + + /// Authentication error + #[error("Authentication error: {0}")] + AuthenticationError(String), + + /// Authorization error + #[error("Authorization error: {0}")] + AuthorizationError(String), + + /// Invalid input data + #[error("Invalid input: {0}")] + InvalidInput(String), + + /// Conflict error (e.g., duplicate resource) + #[error("Conflict: {0}")] + Conflict(String), + + /// Rate limit exceeded + #[error("Rate limit exceeded: {0}")] + RateLimitExceeded(String), + + /// IO error + #[error("IO error: {0}")] + IoError(String), + + /// Serialization/deserialization error + #[error("Serialization error: {0}")] + SerializationError(String), +} + +/// Error code for each error type +impl CommonError { + /// Get the error code for this error + pub fn code(&self) -> &'static str { + match self { + CommonError::NotFound(_) => "NOT_FOUND", + CommonError::ValidationError(_) => "VALIDATION_ERROR", + CommonError::InternalError(_) => "INTERNAL_ERROR", + CommonError::AuthenticationError(_) => "AUTHENTICATION_ERROR", + CommonError::AuthorizationError(_) => "AUTHORIZATION_ERROR", + CommonError::InvalidInput(_) => "INVALID_INPUT", + CommonError::Conflict(_) => "CONFLICT", + CommonError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED", + CommonError::IoError(_) => "IO_ERROR", + CommonError::SerializationError(_) => "SERIALIZATION_ERROR", + } + } + + /// Get the HTTP status code for this error + pub fn http_status_code(&self) -> u16 { + match self { + CommonError::NotFound(_) => 404, + CommonError::ValidationError(_) => 400, + CommonError::InternalError(_) => 500, + CommonError::AuthenticationError(_) => 401, + CommonError::AuthorizationError(_) => 403, + CommonError::InvalidInput(_) => 400, + CommonError::Conflict(_) => 409, + CommonError::RateLimitExceeded(_) => 429, + CommonError::IoError(_) => 500, + CommonError::SerializationError(_) => 400, + } + } + + /// Get the error message + pub fn message(&self) -> &str { + match self { + CommonError::NotFound(msg) => msg, + CommonError::ValidationError(msg) => msg, + CommonError::InternalError(msg) => msg, + CommonError::AuthenticationError(msg) => msg, + CommonError::AuthorizationError(msg) => msg, + CommonError::InvalidInput(msg) => msg, + CommonError::Conflict(msg) => msg, + CommonError::RateLimitExceeded(msg) => msg, + CommonError::IoError(msg) => msg, + CommonError::SerializationError(msg) => msg, + } + } + + /// Check if this error should be logged + pub fn should_log(&self) -> bool { + match self { + CommonError::InternalError(_) | CommonError::IoError(_) => true, + _ => false, + } + } +} + +/// Result type alias for CommonError +pub type CommonResult = Result; + +/// Conversion from std::io::Error +impl From for CommonError { + fn from(err: std::io::Error) -> Self { + CommonError::IoError(err.to_string()) + } +} + +/// Conversion from serde_json::Error +impl From for CommonError { + fn from(err: serde_json::Error) -> Self { + CommonError::SerializationError(err.to_string()) + } +} + +/// Conversion from uuid::Error +impl From for CommonError { + fn from(err: uuid::Error) -> Self { + CommonError::ValidationError(format!("Invalid UUID: {}", err)) + } +} + +/// Conversion from chrono::ParseError +impl From for CommonError { + fn from(err: chrono::ParseError) -> Self { + CommonError::ValidationError(format!("Invalid date format: {}", err)) + } +} + +/// JSON representation of the error for API responses +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, + pub code: String, + pub status: u16, + pub message: String, +} + +impl From<&CommonError> for ErrorResponse { + fn from(err: &CommonError) -> Self { + ErrorResponse { + error: err.code().to_string(), + code: err.code().to_string(), + status: err.http_status_code(), + message: err.message().to_string(), + } + } +} + +impl From for ErrorResponse { + fn from(err: CommonError) -> Self { + ErrorResponse::from(&err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_codes() { + assert_eq!(CommonError::NotFound("test".to_string()).code(), "NOT_FOUND"); + assert_eq!(CommonError::ValidationError("test".to_string()).code(), "VALIDATION_ERROR"); + assert_eq!(CommonError::InternalError("test".to_string()).code(), "INTERNAL_ERROR"); + } + + #[test] + fn test_http_status_codes() { + assert_eq!(CommonError::NotFound("test".to_string()).http_status_code(), 404); + assert_eq!(CommonError::ValidationError("test".to_string()).http_status_code(), 400); + assert_eq!(CommonError::InternalError("test".to_string()).http_status_code(), 500); + assert_eq!(CommonError::AuthenticationError("test".to_string()).http_status_code(), 401); + assert_eq!(CommonError::AuthorizationError("test".to_string()).http_status_code(), 403); + assert_eq!(CommonError::Conflict("test".to_string()).http_status_code(), 409); + assert_eq!(CommonError::RateLimitExceeded("test".to_string()).http_status_code(), 429); + } + + #[test] + fn test_error_message() { + let err = CommonError::NotFound("Resource not found".to_string()); + assert_eq!(err.message(), "Resource not found"); + } + + #[test] + fn test_error_should_log() { + assert!(CommonError::InternalError("test".to_string()).should_log()); + assert!(CommonError::IoError("test".to_string()).should_log()); + assert!(!CommonError::NotFound("test".to_string()).should_log()); + assert!(!CommonError::ValidationError("test".to_string()).should_log()); + } + + #[test] + fn test_from_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"); + let common_err: CommonError = io_err.into(); + match common_err { + CommonError::IoError(msg) => assert!(msg.contains("File not found")), + _ => panic!("Expected IoError"), + } + } + + #[test] + fn test_from_serde_json_error() { + let json_err = serde_json::from_str::("invalid json").unwrap_err(); + let common_err: CommonError = json_err.into(); + match common_err { + CommonError::SerializationError(_) => {}, + _ => panic!("Expected SerializationError"), + } + } + + #[test] + fn test_error_response() { + let err = CommonError::NotFound("Resource not found".to_string()); + let response: ErrorResponse = (&err).into(); + assert_eq!(response.error, "NOT_FOUND"); + assert_eq!(response.code, "NOT_FOUND"); + assert_eq!(response.status, 404); + assert_eq!(response.message, "Resource not found"); + } + + #[test] + fn test_error_serialize() { + let err = CommonError::ValidationError("Invalid input".to_string()); + let json = serde_json::to_string(&err).unwrap(); + assert!(json.contains("ValidationError")); + assert!(json.contains("Invalid input")); + } + + #[test] + fn test_error_deserialize() { + let json = r#"{"type":"ValidationError","message":"Invalid input"}"#; + let err: CommonError = serde_json::from_str(json).unwrap(); + match err { + CommonError::ValidationError(msg) => assert_eq!(msg, "Invalid input"), + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_display() { + let err = CommonError::NotFound("User not found".to_string()); + let display = format!("{}", err); + assert!(display.contains("Not found")); + assert!(display.contains("User not found")); + } + + #[test] + fn test_result_type() { + fn example_function() -> CommonResult { + Ok(42) + } + + fn example_error() -> CommonResult { + Err(CommonError::NotFound("Not found".to_string())) + } + + assert!(example_function().is_ok()); + assert!(example_error().is_err()); + } +} + diff --git a/veza-common/src/lib.rs b/veza-common/src/lib.rs new file mode 100644 index 000000000..8c3855a9f --- /dev/null +++ b/veza-common/src/lib.rs @@ -0,0 +1,14 @@ +//! Veza Common Library +//! +//! This library provides common types and utilities shared across +//! all Veza services (backend, frontend, chat-server, stream-server). + +pub mod types; +pub mod utils; +pub mod error; +pub mod config; + +pub use types::*; +pub use error::{CommonError, CommonResult, ErrorResponse}; +pub use config::*; + diff --git a/veza-common/src/types/mod.rs b/veza-common/src/types/mod.rs new file mode 100644 index 000000000..531bf80c8 --- /dev/null +++ b/veza-common/src/types/mod.rs @@ -0,0 +1,154 @@ +//! Common types module +//! +//! This module organizes shared types into sub-modules for better organization. + +pub mod user; +pub mod track; +pub mod playlist; + +// Re-export types for convenience +pub use user::User; +pub use track::Track; +pub use playlist::Playlist; + +// Re-export type aliases and other common types +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +/// User identifier +pub type UserId = Uuid; + +/// Track identifier +pub type TrackId = Uuid; + +/// Session identifier +pub type SessionId = Uuid; + +/// Conversation identifier +pub type ConversationId = Uuid; + +/// Message identifier +pub type MessageId = Uuid; + +/// Session information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: SessionId, + pub user_id: UserId, + pub track_id: Option, + pub position: u64, // Position in seconds + pub is_playing: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// API response wrapper +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, + pub timestamp: DateTime, +} + +impl ApiResponse { + /// Create a successful response + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + timestamp: Utc::now(), + } + } + + /// Create an error response + pub fn error(message: String) -> Self { + Self { + success: false, + data: None, + error: Some(message), + timestamp: Utc::now(), + } + } +} + +/// Pagination parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginationParams { + pub page: u32, + pub limit: u32, +} + +impl Default for PaginationParams { + fn default() -> Self { + Self { + page: 1, + limit: 20, + } + } +} + +/// Paginated response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginatedResponse { + pub items: Vec, + pub total: u64, + pub page: u32, + pub limit: u32, + pub total_pages: u32, +} + +impl PaginatedResponse { + pub fn new(items: Vec, total: u64, page: u32, limit: u32) -> Self { + let total_pages = (total as f64 / limit as f64).ceil() as u32; + Self { + items, + total, + page, + limit, + total_pages, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_response_success() { + let response = ApiResponse::success("test data"); + assert!(response.success); + assert!(response.data.is_some()); + assert!(response.error.is_none()); + } + + #[test] + fn test_api_response_error() { + let response = ApiResponse::::error("test error".to_string()); + assert!(!response.success); + assert!(response.data.is_none()); + assert!(response.error.is_some()); + } + + #[test] + fn test_pagination_params_default() { + let params = PaginationParams::default(); + assert_eq!(params.page, 1); + assert_eq!(params.limit, 20); + } + + #[test] + fn test_paginated_response() { + let items = vec![1, 2, 3]; + let response = PaginatedResponse::new(items.clone(), 100, 1, 20); + assert_eq!(response.items.len(), 3); + assert_eq!(response.total, 100); + assert_eq!(response.page, 1); + assert_eq!(response.limit, 20); + assert_eq!(response.total_pages, 5); // 100 / 20 = 5 + } +} + diff --git a/veza-common/src/types/playlist.rs b/veza-common/src/types/playlist.rs new file mode 100644 index 000000000..71587b6f3 --- /dev/null +++ b/veza-common/src/types/playlist.rs @@ -0,0 +1,203 @@ +//! Playlist type definition +//! +//! This module defines the Playlist type and related structures. + +use serde::{Deserialize, Serialize}; +use crate::types::{TrackId, UserId}; +use chrono::{DateTime, Utc}; + +/// Playlist identifier +pub type PlaylistId = uuid::Uuid; + +/// Playlist information +/// +/// Represents a playlist in the Veza system. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Playlist { + /// Playlist ID + pub id: PlaylistId, + /// Playlist name + pub name: String, + /// Optional playlist description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// User ID of the playlist owner + pub owner_id: UserId, + /// List of track IDs in the playlist + pub track_ids: Vec, + /// Whether the playlist is public + pub is_public: bool, + /// Playlist creation timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + /// Playlist last update timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, +} + +impl Playlist { + /// Create a new Playlist instance + pub fn new(id: PlaylistId, name: String, owner_id: UserId) -> Self { + Self { + id, + name, + description: None, + owner_id, + track_ids: Vec::new(), + is_public: false, + created_at: None, + updated_at: None, + } + } + + /// Add a track to the playlist + pub fn add_track(&mut self, track_id: TrackId) { + if !self.track_ids.contains(&track_id) { + self.track_ids.push(track_id); + } + } + + /// Remove a track from the playlist + pub fn remove_track(&mut self, track_id: TrackId) { + self.track_ids.retain(|&id| id != track_id); + } + + /// Get the number of tracks in the playlist + pub fn track_count(&self) -> usize { + self.track_ids.len() + } + + /// Check if the playlist is empty + pub fn is_empty(&self) -> bool { + self.track_ids.is_empty() + } + + /// Validate playlist data + pub fn validate(&self) -> Result<(), String> { + if self.name.is_empty() { + return Err("Playlist name cannot be empty".to_string()); + } + if self.name.len() > 200 { + return Err("Playlist name cannot exceed 200 characters".to_string()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn test_playlist_new() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + assert_eq!(playlist.id, playlist_id); + assert_eq!(playlist.name, "My Playlist"); + assert_eq!(playlist.owner_id, owner_id); + assert!(playlist.track_ids.is_empty()); + assert!(!playlist.is_public); + assert!(playlist.description.is_none()); + } + + #[test] + fn test_playlist_serialize() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + let json = serde_json::to_string(&playlist).unwrap(); + assert!(json.contains("\"name\":\"My Playlist\"")); + assert!(json.contains("\"is_public\":false")); + assert!(json.contains("\"track_ids\":[]")); + } + + #[test] + fn test_playlist_deserialize() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let json = format!( + r#"{{"id":"{}","name":"My Playlist","owner_id":"{}","track_ids":[],"is_public":false}}"#, + playlist_id, owner_id + ); + let playlist: Playlist = serde_json::from_str(&json).unwrap(); + assert_eq!(playlist.id, playlist_id); + assert_eq!(playlist.name, "My Playlist"); + assert_eq!(playlist.owner_id, owner_id); + assert!(playlist.track_ids.is_empty()); + } + + #[test] + fn test_playlist_add_track() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let track_id = Uuid::new_v4(); + let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + + playlist.add_track(track_id); + assert_eq!(playlist.track_count(), 1); + assert!(playlist.track_ids.contains(&track_id)); + } + + #[test] + fn test_playlist_add_duplicate_track() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let track_id = Uuid::new_v4(); + let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + + playlist.add_track(track_id); + playlist.add_track(track_id); + assert_eq!(playlist.track_count(), 1); + } + + #[test] + fn test_playlist_remove_track() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let track_id = Uuid::new_v4(); + let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + + playlist.add_track(track_id); + assert_eq!(playlist.track_count(), 1); + + playlist.remove_track(track_id); + assert_eq!(playlist.track_count(), 0); + assert!(playlist.is_empty()); + } + + #[test] + fn test_playlist_is_empty() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + assert!(playlist.is_empty()); + } + + #[test] + fn test_playlist_validate_success() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id); + assert!(playlist.validate().is_ok()); + } + + #[test] + fn test_playlist_validate_empty_name() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new(playlist_id, "".to_string(), owner_id); + assert!(playlist.validate().is_err()); + } + + #[test] + fn test_playlist_validate_name_too_long() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let long_name = "a".repeat(201); + let playlist = Playlist::new(playlist_id, long_name, owner_id); + assert!(playlist.validate().is_err()); + } +} + diff --git a/veza-common/src/types/track.rs b/veza-common/src/types/track.rs new file mode 100644 index 000000000..3eeadf86d --- /dev/null +++ b/veza-common/src/types/track.rs @@ -0,0 +1,163 @@ +//! Track type definition +//! +//! This module defines the Track type and related structures. + +use serde::{Deserialize, Serialize}; +use crate::types::TrackId; +use chrono::{DateTime, Utc}; + +/// Track information +/// +/// Represents an audio track in the Veza system. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Track { + /// Track ID + pub id: TrackId, + /// Track title + pub title: String, + /// Track artist + pub artist: String, + /// Optional album name + #[serde(skip_serializing_if = "Option::is_none")] + pub album: Option, + /// Track duration in seconds + pub duration: u64, + /// Optional file URL + #[serde(skip_serializing_if = "Option::is_none")] + pub file_url: Option, + /// Optional cover image URL + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + /// Track creation timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + /// Track last update timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, +} + +impl Track { + /// Create a new Track instance + pub fn new(id: TrackId, title: String, artist: String, duration: u64) -> Self { + Self { + id, + title, + artist, + album: None, + duration, + file_url: None, + cover_url: None, + created_at: None, + updated_at: None, + } + } + + /// Validate track data + pub fn validate(&self) -> Result<(), String> { + if self.title.is_empty() { + return Err("Title cannot be empty".to_string()); + } + if self.artist.is_empty() { + return Err("Artist cannot be empty".to_string()); + } + if self.duration == 0 { + return Err("Duration must be greater than 0".to_string()); + } + Ok(()) + } + + /// Format duration as MM:SS or HH:MM:SS + pub fn formatted_duration(&self) -> String { + let hours = self.duration / 3600; + let minutes = (self.duration % 3600) / 60; + let seconds = self.duration % 60; + + if hours > 0 { + format!("{}:{:02}:{:02}", hours, minutes, seconds) + } else { + format!("{}:{:02}", minutes, seconds) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn test_track_new() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180); + assert_eq!(track.id, track_id); + assert_eq!(track.title, "Test Song"); + assert_eq!(track.artist, "Test Artist"); + assert_eq!(track.duration, 180); + assert!(track.album.is_none()); + assert!(track.file_url.is_none()); + assert!(track.cover_url.is_none()); + } + + #[test] + fn test_track_serialize() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180); + let json = serde_json::to_string(&track).unwrap(); + assert!(json.contains("\"title\":\"Test Song\"")); + assert!(json.contains("\"artist\":\"Test Artist\"")); + assert!(json.contains("\"duration\":180")); + } + + #[test] + fn test_track_deserialize() { + let track_id = Uuid::new_v4(); + let json = format!( + r#"{{"id":"{}","title":"Test Song","artist":"Test Artist","duration":180}}"#, + track_id + ); + let track: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(track.id, track_id); + assert_eq!(track.title, "Test Song"); + assert_eq!(track.artist, "Test Artist"); + assert_eq!(track.duration, 180); + } + + #[test] + fn test_track_validate_success() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180); + assert!(track.validate().is_ok()); + } + + #[test] + fn test_track_validate_empty_title() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "".to_string(), "Test Artist".to_string(), 180); + assert!(track.validate().is_err()); + } + + #[test] + fn test_track_validate_empty_artist() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "".to_string(), 180); + assert!(track.validate().is_err()); + } + + #[test] + fn test_track_validate_zero_duration() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 0); + assert!(track.validate().is_err()); + } + + #[test] + fn test_track_formatted_duration() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 125); + assert_eq!(track.formatted_duration(), "2:05"); + + let track_long = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 3665); + assert_eq!(track_long.formatted_duration(), "1:01:05"); + } +} + diff --git a/veza-common/src/types/user.rs b/veza-common/src/types/user.rs new file mode 100644 index 000000000..4ea539f52 --- /dev/null +++ b/veza-common/src/types/user.rs @@ -0,0 +1,118 @@ +//! User type definition +//! +//! This module defines the User type and related structures. + +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// User information +/// +/// Represents a user in the Veza system. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct User { + /// User ID (can be database ID or UUID depending on implementation) + pub id: i64, + /// Unique username + pub username: String, + /// User email address + pub email: String, + /// Optional display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Optional avatar URL + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + /// User creation timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + /// User last update timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, +} + +impl User { + /// Create a new User instance + pub fn new(id: i64, username: String, email: String) -> Self { + Self { + id, + username, + email, + display_name: None, + avatar_url: None, + created_at: None, + updated_at: None, + } + } + + /// Validate user data + pub fn validate(&self) -> Result<(), String> { + if self.username.is_empty() { + return Err("Username cannot be empty".to_string()); + } + if self.email.is_empty() { + return Err("Email cannot be empty".to_string()); + } + if !self.email.contains('@') { + return Err("Invalid email format".to_string()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_new() { + let user = User::new(1, "testuser".to_string(), "test@example.com".to_string()); + assert_eq!(user.id, 1); + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + assert!(user.display_name.is_none()); + assert!(user.avatar_url.is_none()); + } + + #[test] + fn test_user_serialize() { + let user = User::new(1, "testuser".to_string(), "test@example.com".to_string()); + let json = serde_json::to_string(&user).unwrap(); + assert!(json.contains("\"id\":1")); + assert!(json.contains("\"username\":\"testuser\"")); + assert!(json.contains("\"email\":\"test@example.com\"")); + } + + #[test] + fn test_user_deserialize() { + let json = r#"{"id":1,"username":"testuser","email":"test@example.com"}"#; + let user: User = serde_json::from_str(json).unwrap(); + assert_eq!(user.id, 1); + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + } + + #[test] + fn test_user_validate_success() { + let user = User::new(1, "testuser".to_string(), "test@example.com".to_string()); + assert!(user.validate().is_ok()); + } + + #[test] + fn test_user_validate_empty_username() { + let user = User::new(1, "".to_string(), "test@example.com".to_string()); + assert!(user.validate().is_err()); + } + + #[test] + fn test_user_validate_empty_email() { + let user = User::new(1, "testuser".to_string(), "".to_string()); + assert!(user.validate().is_err()); + } + + #[test] + fn test_user_validate_invalid_email() { + let user = User::new(1, "testuser".to_string(), "invalid-email".to_string()); + assert!(user.validate().is_err()); + } +} + diff --git a/veza-common/src/utils/date.rs b/veza-common/src/utils/date.rs new file mode 100644 index 000000000..f958a860b --- /dev/null +++ b/veza-common/src/utils/date.rs @@ -0,0 +1,434 @@ +//! Date utilities for Veza services +//! +//! This module provides utility functions for date and time manipulation, +//! formatting, and parsing. + +use chrono::{DateTime, Utc, Local, TimeZone, NaiveDateTime, NaiveDate}; +use crate::error::{CommonError, CommonResult}; + +/// Format a timestamp (seconds since epoch) to a string +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::format_timestamp; +/// +/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC +/// let formatted = format_timestamp(timestamp); +/// assert!(formatted.contains("2021")); +/// ``` +pub fn format_timestamp(ts: i64) -> String { + match Utc.timestamp_opt(ts, 0) { + chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + _ => "Invalid timestamp".to_string(), + } +} + +/// Format a timestamp to ISO 8601 format +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::format_timestamp_iso; +/// +/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC +/// let formatted = format_timestamp_iso(timestamp); +/// assert!(formatted.contains("2021-01-01")); +/// ``` +pub fn format_timestamp_iso(ts: i64) -> String { + match Utc.timestamp_opt(ts, 0) { + chrono::LocalResult::Single(dt) => dt.to_rfc3339(), + _ => "Invalid timestamp".to_string(), + } +} + +/// Format a DateTime to a string with custom format +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::format_datetime; +/// use chrono::{DateTime, Utc}; +/// +/// let dt = Utc::now(); +/// let formatted = format_datetime(&dt, "%Y-%m-%d %H:%M:%S"); +/// assert!(formatted.len() > 0); +/// ``` +pub fn format_datetime(dt: &DateTime, format: &str) -> String { + dt.format(format).to_string() +} + +/// Format a DateTime to ISO 8601 format +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::format_datetime_iso; +/// use chrono::{DateTime, Utc}; +/// +/// let dt = Utc::now(); +/// let formatted = format_datetime_iso(&dt); +/// assert!(formatted.contains("T")); +/// ``` +pub fn format_datetime_iso(dt: &DateTime) -> String { + dt.to_rfc3339() +} + +/// Format a DateTime to a relative time string (e.g., "2 hours ago", "3 days ago") +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::format_relative_time; +/// use chrono::{DateTime, Utc, Duration}; +/// +/// let now = Utc::now(); +/// let past = now - Duration::hours(2); +/// let relative = format_relative_time(&past, &now); +/// assert!(relative.contains("2")); +/// ``` +pub fn format_relative_time(dt: &DateTime, reference: &DateTime) -> String { + let diff = reference.signed_duration_since(*dt); + + if diff.num_seconds() < 60 { + "just now".to_string() + } else if diff.num_minutes() < 60 { + let mins = diff.num_minutes(); + format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" }) + } else if diff.num_hours() < 24 { + let hours = diff.num_hours(); + format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) + } else if diff.num_days() < 30 { + let days = diff.num_days(); + format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) + } else if diff.num_days() < 365 { + let months = diff.num_days() / 30; + format!("{} month{} ago", months, if months == 1 { "" } else { "s" }) + } else { + let years = diff.num_days() / 365; + format!("{} year{} ago", years, if years == 1 { "" } else { "s" }) + } +} + +/// Parse a date string to a DateTime +/// +/// Supports multiple formats including ISO 8601, RFC 3339, and common formats. +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::parse_date; +/// +/// let date_str = "2021-01-01T00:00:00Z"; +/// let dt = parse_date(date_str).unwrap(); +/// assert_eq!(dt.year(), 2021); +/// ``` +pub fn parse_date(s: &str) -> CommonResult> { + // Try ISO 8601 / RFC 3339 first + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + + // Try common formats + let formats = [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%d", + "%d/%m/%Y", + "%m/%d/%Y", + ]; + + for format in &formats { + if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, format) { + return Ok(naive_dt.and_utc()); + } + } + + // Try parsing as date only + if let Ok(naive_date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + if let Some(dt) = naive_date.and_hms_opt(0, 0, 0) { + return Ok(dt.and_utc()); + } + } + + Err(CommonError::ValidationError(format!("Failed to parse date: {}", s))) +} + +/// Parse a timestamp (seconds since epoch) to a DateTime +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::parse_timestamp; +/// +/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC +/// let dt = parse_timestamp(timestamp).unwrap(); +/// assert_eq!(dt.year(), 2021); +/// ``` +pub fn parse_timestamp(ts: i64) -> CommonResult> { + Utc.timestamp_opt(ts, 0) + .single() + .ok_or_else(|| CommonError::ValidationError(format!("Invalid timestamp: {}", ts))) +} + +/// Get current timestamp (seconds since epoch) +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::current_timestamp; +/// +/// let ts = current_timestamp(); +/// assert!(ts > 0); +/// ``` +pub fn current_timestamp() -> i64 { + Utc::now().timestamp() +} + +/// Get current DateTime +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::now; +/// +/// let dt = now(); +/// assert!(dt.year() >= 2020); +/// ``` +pub fn now() -> DateTime { + Utc::now() +} + +/// Convert a DateTime to local time +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::to_local; +/// use chrono::{DateTime, Utc}; +/// +/// let utc = Utc::now(); +/// let local = to_local(&utc); +/// // Local time will be different from UTC +/// ``` +pub fn to_local(dt: &DateTime) -> DateTime { + dt.with_timezone(&Local) +} + +/// Check if a date is in the past +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::is_past; +/// use chrono::{DateTime, Utc, Duration}; +/// +/// let past = Utc::now() - Duration::days(1); +/// assert!(is_past(&past)); +/// ``` +pub fn is_past(dt: &DateTime) -> bool { + *dt < Utc::now() +} + +/// Check if a date is in the future +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::is_future; +/// use chrono::{DateTime, Utc, Duration}; +/// +/// let future = Utc::now() + Duration::days(1); +/// assert!(is_future(&future)); +/// ``` +pub fn is_future(dt: &DateTime) -> bool { + *dt > Utc::now() +} + +/// Get the difference in seconds between two dates +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::seconds_between; +/// use chrono::{DateTime, Utc, Duration}; +/// +/// let dt1 = Utc::now(); +/// let dt2 = dt1 + Duration::seconds(60); +/// assert_eq!(seconds_between(&dt1, &dt2), 60); +/// ``` +pub fn seconds_between(dt1: &DateTime, dt2: &DateTime) -> i64 { + (dt2.timestamp() - dt1.timestamp()).abs() +} + +/// Get the difference in days between two dates +/// +/// # Examples +/// ``` +/// use veza_common::utils::date::days_between; +/// use chrono::{DateTime, Utc, Duration}; +/// +/// let dt1 = Utc::now(); +/// let dt2 = dt1 + Duration::days(7); +/// assert_eq!(days_between(&dt1, &dt2), 7); +/// ``` +pub fn days_between(dt1: &DateTime, dt2: &DateTime) -> i64 { + dt2.signed_duration_since(*dt1).num_days().abs() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, TimeZone, Datelike, Timelike}; + + #[test] + fn test_format_timestamp() { + let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC + let formatted = format_timestamp(timestamp); + assert!(formatted.contains("2021")); + assert!(formatted.contains("00:00:00")); + } + + #[test] + fn test_format_timestamp_iso() { + let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC + let formatted = format_timestamp_iso(timestamp); + assert!(formatted.contains("2021-01-01")); + assert!(formatted.contains("T")); + } + + #[test] + fn test_format_datetime() { + let dt = Utc.with_ymd_and_hms(2021, 1, 1, 12, 30, 45).unwrap(); + let formatted = format_datetime(&dt, "%Y-%m-%d %H:%M:%S"); + assert_eq!(formatted, "2021-01-01 12:30:45"); + } + + #[test] + fn test_format_datetime_iso() { + let dt = Utc.with_ymd_and_hms(2021, 1, 1, 12, 30, 45).unwrap(); + let formatted = format_datetime_iso(&dt); + assert!(formatted.contains("2021-01-01")); + assert!(formatted.contains("T")); + assert!(formatted.contains("12:30:45")); + } + + #[test] + fn test_format_relative_time() { + let now = Utc::now(); + + // Just now + let just_now = now - Duration::seconds(30); + assert!(format_relative_time(&just_now, &now).contains("just now")); + + // Minutes ago + let minutes_ago = now - Duration::minutes(5); + let relative = format_relative_time(&minutes_ago, &now); + assert!(relative.contains("5")); + assert!(relative.contains("minute")); + + // Hours ago + let hours_ago = now - Duration::hours(2); + let relative = format_relative_time(&hours_ago, &now); + assert!(relative.contains("2")); + assert!(relative.contains("hour")); + + // Days ago + let days_ago = now - Duration::days(3); + let relative = format_relative_time(&days_ago, &now); + assert!(relative.contains("3")); + assert!(relative.contains("day")); + } + + #[test] + fn test_parse_date_iso() { + let date_str = "2021-01-01T00:00:00Z"; + let dt = parse_date(date_str).unwrap(); + assert_eq!(dt.year(), 2021); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 1); + } + + #[test] + fn test_parse_date_common_format() { + let date_str = "2021-01-01 12:30:45"; + let dt = parse_date(date_str).unwrap(); + assert_eq!(dt.year(), 2021); + assert_eq!(dt.hour(), 12); + assert_eq!(dt.minute(), 30); + } + + #[test] + fn test_parse_date_date_only() { + let date_str = "2021-01-01"; + let dt = parse_date(date_str).unwrap(); + assert_eq!(dt.year(), 2021); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 1); + assert_eq!(dt.hour(), 0); + } + + #[test] + fn test_parse_date_invalid() { + let date_str = "invalid date"; + let result = parse_date(date_str); + assert!(result.is_err()); + } + + #[test] + fn test_parse_timestamp() { + let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC + let dt = parse_timestamp(timestamp).unwrap(); + assert_eq!(dt.year(), 2021); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 1); + } + + #[test] + fn test_current_timestamp() { + let ts = current_timestamp(); + assert!(ts > 0); + assert!(ts > 1609459200); // Should be after 2021-01-01 + } + + #[test] + fn test_now() { + let dt = now(); + assert!(dt.year() >= 2020); + } + + #[test] + fn test_to_local() { + let utc = Utc.with_ymd_and_hms(2021, 1, 1, 12, 0, 0).unwrap(); + let local = to_local(&utc); + // Local time should be different from UTC (unless in UTC timezone) + assert_eq!(local.year(), 2021); + } + + #[test] + fn test_is_past() { + let past = Utc::now() - Duration::days(1); + assert!(is_past(&past)); + + let future = Utc::now() + Duration::days(1); + assert!(!is_past(&future)); + } + + #[test] + fn test_is_future() { + let future = Utc::now() + Duration::days(1); + assert!(is_future(&future)); + + let past = Utc::now() - Duration::days(1); + assert!(!is_future(&past)); + } + + #[test] + fn test_seconds_between() { + let dt1 = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap(); + let dt2 = dt1 + Duration::seconds(60); + assert_eq!(seconds_between(&dt1, &dt2), 60); + + // Should work in reverse order too + assert_eq!(seconds_between(&dt2, &dt1), 60); + } + + #[test] + fn test_days_between() { + let dt1 = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap(); + let dt2 = dt1 + Duration::days(7); + assert_eq!(days_between(&dt1, &dt2), 7); + + // Should work in reverse order too + assert_eq!(days_between(&dt2, &dt1), 7); + } +} + diff --git a/veza-common/src/utils/logging.rs b/veza-common/src/utils/logging.rs new file mode 100644 index 000000000..2499f27d6 --- /dev/null +++ b/veza-common/src/utils/logging.rs @@ -0,0 +1,316 @@ +//! Logging utilities for Veza services +//! +//! This module provides utility functions for structured logging across +//! all Veza services. + +use std::collections::HashMap; +use std::fmt::Display; + +/// Log a request with structured information +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_request; +/// +/// log_request("api", "GET", "/users"); +/// ``` +pub fn log_request(service: &str, method: &str, path: &str) { + // Uses eprintln! for compatibility, can be replaced with tracing/log in implementations + eprintln!("[{}] {} {} - Request received", service, method, path); +} + +/// Log a request with additional context +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_request_with_context; +/// use std::collections::HashMap; +/// +/// let mut context = HashMap::new(); +/// context.insert("user_id".to_string(), "123".to_string()); +/// context.insert("ip".to_string(), "192.168.1.1".to_string()); +/// +/// log_request_with_context("api", "GET", "/users", &context); +/// ``` +pub fn log_request_with_context( + service: &str, + method: &str, + path: &str, + context: &HashMap, +) { + eprint!("[{}] {} {} - Request received", service, method, path); + if !context.is_empty() { + eprint!(" | Context: "); + for (key, value) in context { + eprint!("{}={} ", key, value); + } + } + eprintln!(); +} + +/// Log an error with context +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_error; +/// +/// log_error("api", "Database connection failed", None); +/// ``` +pub fn log_error(service: &str, message: &str, error: Option<&dyn Display>) { + if let Some(err) = error { + eprintln!("[ERROR] [{}] {} - Error: {}", service, message, err); + } else { + eprintln!("[ERROR] [{}] {}", service, message); + } +} + +/// Log a warning with context +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_warning; +/// +/// log_warning("api", "Rate limit approaching"); +/// ``` +pub fn log_warning(service: &str, message: &str) { + eprintln!("[WARN] [{}] {}", service, message); +} + +/// Log an info message +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_info; +/// +/// log_info("api", "Service started"); +/// ``` +pub fn log_info(service: &str, message: &str) { + eprintln!("[INFO] [{}] {}", service, message); +} + +/// Log a debug message +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::log_debug; +/// +/// log_debug("api", "Processing user request"); +/// ``` +pub fn log_debug(service: &str, message: &str) { + eprintln!("[DEBUG] [{}] {}", service, message); +} + +/// Create a formatted log message +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::format_log_message; +/// +/// let message = format_log_message("api", "INFO", "Service started"); +/// assert!(message.contains("api")); +/// assert!(message.contains("INFO")); +/// ``` +pub fn format_log_message(service: &str, level: &str, message: &str) -> String { + format!("[{}] [{}] {}", level, service, message) +} + +/// Create a formatted log message with timestamp +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::format_log_message_with_timestamp; +/// +/// let message = format_log_message_with_timestamp("api", "INFO", "Service started"); +/// assert!(message.contains("api")); +/// assert!(message.contains("INFO")); +/// ``` +pub fn format_log_message_with_timestamp(service: &str, level: &str, message: &str) -> String { + use chrono::Utc; + let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); + format!("[{}] [{}] [{}] {}", timestamp, level, service, message) +} + +/// Create a structured log entry +/// +/// # Examples +/// ``` +/// use veza_common::utils::logging::StructuredLogEntry; +/// +/// let entry = StructuredLogEntry::new("api", "INFO", "Service started"); +/// let json = entry.to_json(); +/// assert!(json.contains("api")); +/// ``` +#[derive(Debug, Clone)] +pub struct StructuredLogEntry { + pub timestamp: String, + pub service: String, + pub level: String, + pub message: String, + pub context: HashMap, +} + +impl StructuredLogEntry { + /// Create a new structured log entry + pub fn new(service: &str, level: &str, message: &str) -> Self { + use chrono::Utc; + Self { + timestamp: Utc::now().to_rfc3339(), + service: service.to_string(), + level: level.to_string(), + message: message.to_string(), + context: HashMap::new(), + } + } + + /// Add context to the log entry + pub fn with_context(mut self, key: String, value: String) -> Self { + self.context.insert(key, value); + self + } + + /// Add multiple context fields + pub fn with_contexts(mut self, contexts: HashMap) -> Self { + self.context.extend(contexts); + self + } + + /// Convert to JSON string + pub fn to_json(&self) -> String { + use serde_json::json; + let mut json_obj = json!({ + "timestamp": self.timestamp, + "service": self.service, + "level": self.level, + "message": self.message, + }); + + if !self.context.is_empty() { + json_obj["context"] = json!(self.context); + } + + serde_json::to_string(&json_obj).unwrap_or_else(|_| "{}".to_string()) + } + + /// Convert to formatted string + pub fn to_string(&self) -> String { + format_log_message_with_timestamp(&self.service, &self.level, &self.message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_format_log_message() { + let message = format_log_message("api", "INFO", "Test message"); + assert!(message.contains("api")); + assert!(message.contains("INFO")); + assert!(message.contains("Test message")); + } + + #[test] + fn test_format_log_message_with_timestamp() { + let message = format_log_message_with_timestamp("api", "INFO", "Test message"); + assert!(message.contains("api")); + assert!(message.contains("INFO")); + assert!(message.contains("Test message")); + assert!(message.contains("UTC")); + } + + #[test] + fn test_structured_log_entry_new() { + let entry = StructuredLogEntry::new("api", "INFO", "Test message"); + assert_eq!(entry.service, "api"); + assert_eq!(entry.level, "INFO"); + assert_eq!(entry.message, "Test message"); + assert!(!entry.timestamp.is_empty()); + assert!(entry.context.is_empty()); + } + + #[test] + fn test_structured_log_entry_with_context() { + let entry = StructuredLogEntry::new("api", "INFO", "Test message") + .with_context("user_id".to_string(), "123".to_string()); + + assert_eq!(entry.context.get("user_id"), Some(&"123".to_string())); + } + + #[test] + fn test_structured_log_entry_with_contexts() { + let mut contexts = HashMap::new(); + contexts.insert("user_id".to_string(), "123".to_string()); + contexts.insert("ip".to_string(), "192.168.1.1".to_string()); + + let entry = StructuredLogEntry::new("api", "INFO", "Test message") + .with_contexts(contexts); + + assert_eq!(entry.context.len(), 2); + assert_eq!(entry.context.get("user_id"), Some(&"123".to_string())); + assert_eq!(entry.context.get("ip"), Some(&"192.168.1.1".to_string())); + } + + #[test] + fn test_structured_log_entry_to_json() { + let entry = StructuredLogEntry::new("api", "INFO", "Test message") + .with_context("user_id".to_string(), "123".to_string()); + + let json = entry.to_json(); + assert!(json.contains("api")); + assert!(json.contains("INFO")); + assert!(json.contains("Test message")); + assert!(json.contains("user_id")); + assert!(json.contains("123")); + } + + #[test] + fn test_structured_log_entry_to_string() { + let entry = StructuredLogEntry::new("api", "INFO", "Test message"); + let formatted = entry.to_string(); + assert!(formatted.contains("api")); + assert!(formatted.contains("INFO")); + assert!(formatted.contains("Test message")); + } + + #[test] + fn test_log_request() { + // This test just verifies the function doesn't panic + log_request("api", "GET", "/users"); + } + + #[test] + fn test_log_request_with_context() { + let mut context = HashMap::new(); + context.insert("user_id".to_string(), "123".to_string()); + + // This test just verifies the function doesn't panic + log_request_with_context("api", "GET", "/users", &context); + } + + #[test] + fn test_log_error() { + // This test just verifies the function doesn't panic + log_error("api", "Test error", None); + log_error("api", "Test error with details", Some(&"Connection timeout")); + } + + #[test] + fn test_log_warning() { + // This test just verifies the function doesn't panic + log_warning("api", "Test warning"); + } + + #[test] + fn test_log_info() { + // This test just verifies the function doesn't panic + log_info("api", "Test info"); + } + + #[test] + fn test_log_debug() { + // This test just verifies the function doesn't panic + log_debug("api", "Test debug"); + } +} + diff --git a/veza-common/src/utils/mod.rs b/veza-common/src/utils/mod.rs new file mode 100644 index 000000000..4e38baa4f --- /dev/null +++ b/veza-common/src/utils/mod.rs @@ -0,0 +1,111 @@ +//! Utilities module +//! +//! This module provides utility functions and helpers. + +pub mod validation; +pub mod serialization; +pub mod date; +pub mod logging; + +pub use validation::*; +pub use serialization::*; +pub use date::*; +pub use logging::*; + +// Re-export other utility functions +use uuid::Uuid; + +/// Generate a new UUID v4 +pub fn generate_uuid() -> Uuid { + Uuid::new_v4() +} + +/// Format a duration in seconds to a human-readable string +pub fn format_duration(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + + if hours > 0 { + format!("{}:{:02}:{:02}", hours, minutes, secs) + } else { + format!("{}:{:02}", minutes, secs) + } +} + +/// Parse a duration string to seconds +/// Supports formats: "MM:SS", "HH:MM:SS" +pub fn parse_duration(duration_str: &str) -> Option { + let parts: Vec<&str> = duration_str.split(':').collect(); + + match parts.len() { + 2 => { + // MM:SS format + let minutes: u64 = parts[0].parse().ok()?; + let seconds: u64 = parts[1].parse().ok()?; + Some(minutes * 60 + seconds) + } + 3 => { + // HH:MM:SS format + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + Some(hours * 3600 + minutes * 60 + seconds) + } + _ => None, + } +} + +/// Format a file size in bytes to a human-readable string +pub fn format_file_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + const THRESHOLD: f64 = 1024.0; + + if bytes == 0 { + return "0 B".to_string(); + } + + let bytes_f64 = bytes as f64; + let exp = (bytes_f64.ln() / THRESHOLD.ln()) as u32; + let exp = exp.min((UNITS.len() - 1) as u32); + let value = bytes_f64 / THRESHOLD.powi(exp as i32); + + format!("{:.2} {}", value, UNITS[exp as usize]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_uuid() { + let uuid1 = generate_uuid(); + let uuid2 = generate_uuid(); + assert_ne!(uuid1, uuid2); + } + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "0:00"); + assert_eq!(format_duration(65), "1:05"); + assert_eq!(format_duration(3665), "1:01:05"); + assert_eq!(format_duration(3600), "1:00:00"); + } + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("1:05"), Some(65)); + assert_eq!(parse_duration("1:01:05"), Some(3665)); + assert_eq!(parse_duration("invalid"), None); + assert_eq!(parse_duration("1:2:3:4"), None); + } + + #[test] + fn test_format_file_size() { + assert_eq!(format_file_size(0), "0 B"); + assert_eq!(format_file_size(1024), "1.00 KB"); + assert_eq!(format_file_size(1048576), "1.00 MB"); + assert_eq!(format_file_size(1073741824), "1.00 GB"); + } +} + diff --git a/veza-common/src/utils/serialization.rs b/veza-common/src/utils/serialization.rs new file mode 100644 index 000000000..1d470e56f --- /dev/null +++ b/veza-common/src/utils/serialization.rs @@ -0,0 +1,383 @@ +//! Serialization utilities for Veza services +//! +//! This module provides helper functions for serialization and deserialization +//! using serde, with proper error handling. + +use serde::{Deserialize, Serialize}; +use crate::error::{CommonError, CommonResult}; + +/// Serialize a value to a JSON string +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::to_json; +/// use serde::Serialize; +/// +/// #[derive(Serialize)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let user = User { name: "John".to_string(), age: 30 }; +/// let json = to_json(&user).unwrap(); +/// assert!(json.contains("John")); +/// ``` +pub fn to_json(value: &T) -> CommonResult { + serde_json::to_string(value) + .map_err(|e| CommonError::SerializationError(format!("Failed to serialize to JSON: {}", e))) +} + +/// Serialize a value to a pretty-printed JSON string +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::to_json_pretty; +/// use serde::Serialize; +/// +/// #[derive(Serialize)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let user = User { name: "John".to_string(), age: 30 }; +/// let json = to_json_pretty(&user).unwrap(); +/// assert!(json.contains("John")); +/// ``` +pub fn to_json_pretty(value: &T) -> CommonResult { + serde_json::to_string_pretty(value) + .map_err(|e| CommonError::SerializationError(format!("Failed to serialize to pretty JSON: {}", e))) +} + +/// Deserialize a value from a JSON string +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::from_json; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize, Debug)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let json = r#"{"name":"John","age":30}"#; +/// let user: User = from_json(json).unwrap(); +/// assert_eq!(user.name, "John"); +/// ``` +pub fn from_json<'a, T: Deserialize<'a>>(s: &'a str) -> CommonResult { + serde_json::from_str(s) + .map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON: {}", e))) +} + +/// Deserialize a value from a JSON byte slice +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::from_json_bytes; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize, Debug)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let json_bytes = br#"{"name":"John","age":30}"#; +/// let user: User = from_json_bytes(json_bytes).unwrap(); +/// assert_eq!(user.name, "John"); +/// ``` +pub fn from_json_bytes Deserialize<'de>>(bytes: &[u8]) -> CommonResult { + serde_json::from_slice(bytes) + .map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON bytes: {}", e))) +} + +/// Deserialize a value from a JSON value +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::from_json_value; +/// use serde::Deserialize; +/// use serde_json::json; +/// +/// #[derive(Deserialize, Debug)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let json_value = json!({"name": "John", "age": 30}); +/// let user: User = from_json_value(&json_value).unwrap(); +/// assert_eq!(user.name, "John"); +/// ``` +pub fn from_json_value(value: &serde_json::Value) -> CommonResult { + serde_json::from_value(value.clone()) + .map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON value: {}", e))) +} + +/// Serialize a value to a JSON value +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::to_json_value; +/// use serde::Serialize; +/// +/// #[derive(Serialize)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let user = User { name: "John".to_string(), age: 30 }; +/// let json_value = to_json_value(&user).unwrap(); +/// assert_eq!(json_value["name"], "John"); +/// ``` +pub fn to_json_value(value: &T) -> CommonResult { + serde_json::to_value(value) + .map_err(|e| CommonError::SerializationError(format!("Failed to serialize to JSON value: {}", e))) +} + +/// Clone a value by serializing and deserializing it +/// +/// This is useful for deep cloning types that don't implement Clone. +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::clone_via_json; +/// use serde::{Serialize, Deserialize}; +/// +/// #[derive(Serialize, Deserialize, PartialEq, Debug)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// let user = User { name: "John".to_string(), age: 30 }; +/// let cloned = clone_via_json(&user).unwrap(); +/// assert_eq!(user, cloned); +/// ``` +pub fn clone_via_json Deserialize<'de>>(value: &T) -> CommonResult { + let json = to_json(value)?; + from_json(&json) +} + +/// Validate that a string is valid JSON +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::is_valid_json; +/// +/// assert!(is_valid_json(r#"{"key":"value"}"#)); +/// assert!(!is_valid_json("invalid json")); +/// ``` +pub fn is_valid_json(s: &str) -> bool { + serde_json::from_str::(s).is_ok() +} + +/// Pretty print a JSON string +/// +/// # Examples +/// ``` +/// use veza_common::utils::serialization::pretty_print_json; +/// +/// let json = r#"{"name":"John","age":30}"#; +/// let pretty = pretty_print_json(json).unwrap(); +/// assert!(pretty.contains('\n')); // Pretty printed should have newlines +/// ``` +pub fn pretty_print_json(s: &str) -> CommonResult { + let value: serde_json::Value = from_json(s)?; + to_json_pretty(&value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] + struct TestUser { + id: u32, + name: String, + email: String, + active: bool, + } + + #[test] + fn test_to_json() { + let user = TestUser { + id: 1, + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + active: true, + }; + + let json = to_json(&user).unwrap(); + assert!(json.contains("John Doe")); + assert!(json.contains("john@example.com")); + assert!(json.contains("\"id\":1")); + assert!(json.contains("\"active\":true")); + } + + #[test] + fn test_to_json_pretty() { + let user = TestUser { + id: 1, + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + active: true, + }; + + let json = to_json_pretty(&user).unwrap(); + assert!(json.contains("John Doe")); + assert!(json.contains('\n')); // Pretty printed should have newlines + } + + #[test] + fn test_from_json() { + let json = r#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#; + let user: TestUser = from_json(json).unwrap(); + + assert_eq!(user.id, 1); + assert_eq!(user.name, "John Doe"); + assert_eq!(user.email, "john@example.com"); + assert_eq!(user.active, true); + } + + #[test] + fn test_from_json_invalid() { + let json = r#"{"invalid":"json"}"#; + let result: Result = from_json(json); + assert!(result.is_err()); + } + + #[test] + fn test_from_json_bytes() { + let json_bytes = br#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#; + let user: TestUser = from_json_bytes(json_bytes).unwrap(); + + assert_eq!(user.id, 1); + assert_eq!(user.name, "John Doe"); + } + + #[test] + fn test_from_json_value() { + let json_value = serde_json::json!({ + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "active": true + }); + + let user: TestUser = from_json_value(&json_value).unwrap(); + assert_eq!(user.id, 1); + assert_eq!(user.name, "John Doe"); + } + + #[test] + fn test_to_json_value() { + let user = TestUser { + id: 1, + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + active: true, + }; + + let json_value = to_json_value(&user).unwrap(); + assert_eq!(json_value["id"], 1); + assert_eq!(json_value["name"], "John Doe"); + assert_eq!(json_value["email"], "john@example.com"); + assert_eq!(json_value["active"], true); + } + + #[test] + fn test_clone_via_json() { + let user = TestUser { + id: 1, + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + active: true, + }; + + let cloned = clone_via_json(&user).unwrap(); + assert_eq!(user, cloned); + } + + #[test] + fn test_is_valid_json() { + assert!(is_valid_json(r#"{"key":"value"}"#)); + assert!(is_valid_json(r#"{"number":123}"#)); + assert!(is_valid_json(r#"{"array":[1,2,3]}"#)); + assert!(is_valid_json("null")); + assert!(is_valid_json("true")); + assert!(is_valid_json("123")); + assert!(!is_valid_json("invalid json")); + assert!(!is_valid_json("{invalid}")); + assert!(!is_valid_json("")); + } + + #[test] + fn test_pretty_print_json() { + let json = r#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#; + let pretty = pretty_print_json(json).unwrap(); + + assert!(pretty.contains("John Doe")); + assert!(pretty.contains('\n')); // Should be formatted with newlines + assert!(pretty.contains(" ")); // Should have indentation + } + + #[test] + fn test_pretty_print_invalid_json() { + let json = "invalid json"; + let result = pretty_print_json(json); + assert!(result.is_err()); + } + + #[test] + fn test_round_trip_serialization() { + let user = TestUser { + id: 42, + name: "Jane Smith".to_string(), + email: "jane@example.com".to_string(), + active: false, + }; + + // Serialize + let json = to_json(&user).unwrap(); + + // Deserialize + let deserialized: TestUser = from_json(&json).unwrap(); + + assert_eq!(user, deserialized); + } + + #[test] + fn test_nested_structure() { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct Address { + street: String, + city: String, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct Person { + name: String, + address: Address, + } + + let person = Person { + name: "John".to_string(), + address: Address { + street: "123 Main St".to_string(), + city: "New York".to_string(), + }, + }; + + let json = to_json(&person).unwrap(); + let deserialized: Person = from_json(&json).unwrap(); + + assert_eq!(person, deserialized); + } +} + diff --git a/veza-common/src/utils/validation.rs b/veza-common/src/utils/validation.rs new file mode 100644 index 000000000..37f2e5008 --- /dev/null +++ b/veza-common/src/utils/validation.rs @@ -0,0 +1,454 @@ +//! Validation utilities for Veza services +//! +//! This module provides validation functions for common data types +//! such as email addresses, usernames, passwords, URLs, etc. + +use crate::error::{CommonError, CommonResult}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + // Email validation regex - RFC 5322 compliant + static ref EMAIL_REGEX: Regex = Regex::new( + r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + ).unwrap(); + + // Username validation regex - 3-30 characters, alphanumeric and underscores + static ref USERNAME_REGEX: Regex = Regex::new( + r"^[a-zA-Z0-9_]{3,30}$" + ).unwrap(); + + // Password validation regex - at least 8 characters, alphanumeric and special chars + // Note: We'll validate letter and number requirements separately + static ref PASSWORD_REGEX: Regex = Regex::new( + r"^[A-Za-z\d@$!%*#?&]{8,}$" + ).unwrap(); + + // URL validation regex + static ref URL_REGEX: Regex = Regex::new( + r"^https?://(?:[-\w.])+(?:[:\d]+)?(?:/(?:[\w/_.])*)?(?:\?(?:[\w&=%.])*)?(?:#(?:[\w.])*)?$" + ).unwrap(); + + // Phone number validation regex - international format (E.164) + // Must be 10-15 digits, optionally starting with + + static ref PHONE_REGEX: Regex = Regex::new( + r"^\+?[1-9]\d{9,14}$" + ).unwrap(); +} + +/// Validate an email address +/// +/// Returns true if the email address is valid according to RFC 5322. +/// +/// # Examples +/// ``` +/// use veza_common::utils::validation::validate_email; +/// +/// assert!(validate_email("test@example.com")); +/// assert!(validate_email("user.name@example.co.uk")); +/// assert!(!validate_email("invalid-email")); +/// ``` +pub fn validate_email(email: &str) -> bool { + if email.is_empty() || email.len() > 254 { + return false; + } + + // Check basic structure: must contain @ and . + if !email.contains('@') || !email.contains('.') { + return false; + } + + // Split by @ and check parts + let parts: Vec<&str> = email.split('@').collect(); + if parts.len() != 2 { + return false; + } + + let domain = parts[1]; + // Domain must contain at least one dot after @ + if !domain.contains('.') { + return false; + } + + EMAIL_REGEX.is_match(email) +} + +/// Validate an email address and return a Result +/// +/// Returns Ok(()) if the email is valid, or an error if invalid. +pub fn validate_email_result(email: &str) -> CommonResult<()> { + if validate_email(email) { + Ok(()) + } else { + Err(CommonError::ValidationError(format!("Invalid email address: {}", email))) + } +} + +/// Validate a username +/// +/// Username must be: +/// - 3 to 30 characters long +/// - Contains only alphanumeric characters and underscores +/// +/// # Examples +/// ``` +/// use veza_common::utils::validation::validate_username; +/// +/// assert!(validate_username("user123")); +/// assert!(validate_username("test_user")); +/// assert!(!validate_username("ab")); // Too short +/// assert!(!validate_username("user-name")); // Contains hyphen +/// ``` +pub fn validate_username(username: &str) -> bool { + USERNAME_REGEX.is_match(username) +} + +/// Validate a username and return a Result +/// +/// Returns Ok(()) if the username is valid, or an error if invalid. +pub fn validate_username_result(username: &str) -> CommonResult<()> { + if validate_username(username) { + Ok(()) + } else { + Err(CommonError::ValidationError( + format!("Invalid username: must be 3-30 characters, alphanumeric and underscores only") + )) + } +} + +/// Validate a password +/// +/// Password must be: +/// - At least 8 characters long +/// - Contains at least one letter +/// - Contains at least one number +/// - May contain special characters: @$!%*#?& +/// +/// # Examples +/// ``` +/// use veza_common::utils::validation::validate_password; +/// +/// assert!(validate_password("Password123")); +/// assert!(validate_password("MyP@ssw0rd")); +/// assert!(!validate_password("short")); // Too short +/// assert!(!validate_password("NoNumbers")); // No numbers +/// ``` +pub fn validate_password(password: &str) -> bool { + if password.is_empty() || password.len() < 8 { + return false; + } + + // Check format (alphanumeric and allowed special chars) + if !PASSWORD_REGEX.is_match(password) { + return false; + } + + // Check for at least one letter + let has_letter = password.chars().any(|c| c.is_alphabetic()); + if !has_letter { + return false; + } + + // Check for at least one number + let has_number = password.chars().any(|c| c.is_ascii_digit()); + if !has_number { + return false; + } + + true +} + +/// Validate a password and return a Result +/// +/// Returns Ok(()) if the password is valid, or an error if invalid. +pub fn validate_password_result(password: &str) -> CommonResult<()> { + if validate_password(password) { + Ok(()) + } else { + Err(CommonError::ValidationError( + format!("Invalid password: must be at least 8 characters with at least one letter and one number") + )) + } +} + +/// Validate a URL +/// +/// Validates HTTP/HTTPS URLs. +/// +/// # Examples +/// ``` +/// use veza_common::utils::validation::validate_url; +/// +/// assert!(validate_url("https://example.com")); +/// assert!(validate_url("http://example.com/path?query=value")); +/// assert!(!validate_url("not-a-url")); +/// ``` +pub fn validate_url(url: &str) -> bool { + if url.is_empty() { + return false; + } + URL_REGEX.is_match(url) +} + +/// Validate a URL and return a Result +/// +/// Returns Ok(()) if the URL is valid, or an error if invalid. +pub fn validate_url_result(url: &str) -> CommonResult<()> { + if validate_url(url) { + Ok(()) + } else { + Err(CommonError::ValidationError(format!("Invalid URL: {}", url))) + } +} + +/// Validate a phone number +/// +/// Validates international phone number format (E.164). +/// +/// # Examples +/// ``` +/// use veza_common::utils::validation::validate_phone; +/// +/// assert!(validate_phone("+1234567890")); +/// assert!(validate_phone("1234567890")); +/// assert!(!validate_phone("123")); // Too short +/// ``` +pub fn validate_phone(phone: &str) -> bool { + if phone.is_empty() { + return false; + } + PHONE_REGEX.is_match(phone) +} + +/// Validate a phone number and return a Result +/// +/// Returns Ok(()) if the phone number is valid, or an error if invalid. +pub fn validate_phone_result(phone: &str) -> CommonResult<()> { + if validate_phone(phone) { + Ok(()) + } else { + Err(CommonError::ValidationError(format!("Invalid phone number: {}", phone))) + } +} + +/// Validate a string length +/// +/// Returns true if the string length is within the specified range (inclusive). +pub fn validate_length(value: &str, min: usize, max: usize) -> bool { + let len = value.len(); + len >= min && len <= max +} + +/// Validate a string length and return a Result +pub fn validate_length_result(value: &str, min: usize, max: usize) -> CommonResult<()> { + if validate_length(value, min, max) { + Ok(()) + } else { + Err(CommonError::ValidationError( + format!("String length must be between {} and {} characters", min, max) + )) + } +} + +/// Validate that a string is not empty +pub fn validate_not_empty(value: &str) -> bool { + !value.trim().is_empty() +} + +/// Validate that a string is not empty and return a Result +pub fn validate_not_empty_result(value: &str) -> CommonResult<()> { + if validate_not_empty(value) { + Ok(()) + } else { + Err(CommonError::ValidationError("Value cannot be empty".to_string())) + } +} + +/// Validate a numeric range +/// +/// Returns true if the value is within the specified range (inclusive). +pub fn validate_range(value: &T, min: &T, max: &T) -> bool { + value >= min && value <= max +} + +/// Validate a numeric range and return a Result +pub fn validate_range_result(value: T, min: T, max: T) -> CommonResult<()> { + if validate_range(&value, &min, &max) { + Ok(()) + } else { + Err(CommonError::ValidationError( + format!("Value must be between {} and {}", min, max) + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_email() { + // Valid emails + assert!(validate_email("test@example.com")); + assert!(validate_email("user.name@example.com")); + assert!(validate_email("user+tag@example.co.uk")); + assert!(validate_email("user_name@example.com")); + assert!(validate_email("user123@example123.com")); + assert!(validate_email("a@b.co")); + + // Invalid emails + assert!(!validate_email("")); + assert!(!validate_email("invalid-email")); + assert!(!validate_email("@example.com")); + assert!(!validate_email("test@")); + assert!(!validate_email("test@.com")); + assert!(!validate_email("test @example.com")); + assert!(!validate_email("test@example")); + assert!(!validate_email(&"a".repeat(255))); // Too long + } + + #[test] + fn test_validate_email_result() { + assert!(validate_email_result("test@example.com").is_ok()); + assert!(validate_email_result("invalid-email").is_err()); + } + + #[test] + fn test_validate_username() { + // Valid usernames + assert!(validate_username("user123")); + assert!(validate_username("test_user")); + assert!(validate_username("abc")); + assert!(validate_username("User123")); + assert!(validate_username("_user_")); + assert!(validate_username(&"a".repeat(30))); // Max length + + // Invalid usernames + assert!(!validate_username("")); + assert!(!validate_username("ab")); // Too short + assert!(!validate_username(&"a".repeat(31))); // Too long + assert!(!validate_username("user-name")); // Contains hyphen + assert!(!validate_username("user name")); // Contains space + assert!(!validate_username("user.name")); // Contains dot + assert!(!validate_username("user@name")); // Contains @ + } + + #[test] + fn test_validate_username_result() { + assert!(validate_username_result("user123").is_ok()); + assert!(validate_username_result("ab").is_err()); + } + + #[test] + fn test_validate_password() { + // Valid passwords + assert!(validate_password("Password123")); + assert!(validate_password("MyP@ssw0rd")); + assert!(validate_password("test123456")); + assert!(validate_password("ABCdef123")); + assert!(validate_password("Pass@123")); + + // Invalid passwords + assert!(!validate_password("")); + assert!(!validate_password("short")); // Too short + assert!(!validate_password("NoNumbers")); // No numbers + assert!(!validate_password("12345678")); // No letters + assert!(!validate_password("abcdefgh")); // No numbers + } + + #[test] + fn test_validate_password_result() { + assert!(validate_password_result("Password123").is_ok()); + assert!(validate_password_result("short").is_err()); + } + + #[test] + fn test_validate_url() { + // Valid URLs + assert!(validate_url("https://example.com")); + assert!(validate_url("http://example.com")); + assert!(validate_url("https://example.com/path")); + assert!(validate_url("https://example.com/path?query=value")); + assert!(validate_url("https://example.com/path?query=value#fragment")); + assert!(validate_url("http://subdomain.example.com")); + + // Invalid URLs + assert!(!validate_url("")); + assert!(!validate_url("not-a-url")); + assert!(!validate_url("example.com")); // Missing protocol + assert!(!validate_url("ftp://example.com")); // Unsupported protocol + } + + #[test] + fn test_validate_url_result() { + assert!(validate_url_result("https://example.com").is_ok()); + assert!(validate_url_result("not-a-url").is_err()); + } + + #[test] + fn test_validate_phone() { + // Valid phone numbers + assert!(validate_phone("+1234567890")); + assert!(validate_phone("1234567890")); + assert!(validate_phone("+12345678901234")); + + // Invalid phone numbers + assert!(!validate_phone("")); + assert!(!validate_phone("123")); // Too short (less than 10 digits) + assert!(!validate_phone("123456789")); // Too short (9 digits) + assert!(!validate_phone("+0123456789")); // Starts with 0 + assert!(!validate_phone("123-456-7890")); // Contains hyphens + assert!(!validate_phone("(123) 456-7890")); // Contains parentheses + } + + #[test] + fn test_validate_phone_result() { + assert!(validate_phone_result("+1234567890").is_ok()); + assert!(validate_phone_result("123").is_err()); + } + + #[test] + fn test_validate_length() { + assert!(validate_length("abc", 3, 5)); + assert!(validate_length("abc", 3, 3)); + assert!(validate_length("abcde", 3, 5)); + assert!(!validate_length("ab", 3, 5)); // Too short + assert!(!validate_length("abcdef", 3, 5)); // Too long + } + + #[test] + fn test_validate_length_result() { + assert!(validate_length_result("abc", 3, 5).is_ok()); + assert!(validate_length_result("ab", 3, 5).is_err()); + } + + #[test] + fn test_validate_not_empty() { + assert!(validate_not_empty("test")); + assert!(validate_not_empty(" test ")); + assert!(!validate_not_empty("")); + assert!(!validate_not_empty(" ")); + } + + #[test] + fn test_validate_not_empty_result() { + assert!(validate_not_empty_result("test").is_ok()); + assert!(validate_not_empty_result("").is_err()); + } + + #[test] + fn test_validate_range() { + assert!(validate_range(&5, &1, &10)); + assert!(validate_range(&1, &1, &10)); + assert!(validate_range(&10, &1, &10)); + assert!(!validate_range(&0, &1, &10)); // Too low + assert!(!validate_range(&11, &1, &10)); // Too high + } + + #[test] + fn test_validate_range_result() { + assert!(validate_range_result(5, 1, 10).is_ok()); + assert!(validate_range_result(0, 1, 10).is_err()); + } +} + diff --git a/veza-common/tests/common_tests.rs b/veza-common/tests/common_tests.rs new file mode 100644 index 000000000..b00a57717 --- /dev/null +++ b/veza-common/tests/common_tests.rs @@ -0,0 +1,429 @@ +//! Common tests for Veza Common Library +//! +//! This module provides integration tests and test utilities for the Veza common library. + +use veza_common::utils::{ + generate_uuid, format_duration, format_file_size, + validate_email, validate_username, validate_password, + validate_email_result, validate_username_result, + to_json, from_json, + format_timestamp, parse_date, format_relative_time, + format_log_message, StructuredLogEntry, +}; +use veza_common::types::{ + Track, +}; +use veza_common::config::{ + DatabaseConfig, RedisConfig, +}; +use veza_common::error::{ + CommonError, ErrorResponse, +}; + +/// Test fixtures for common library tests +pub mod fixtures { + use veza_common::types::{User, Track, Playlist}; + use veza_common::config::{DatabaseConfig, RedisConfig}; + use uuid::Uuid; + use std::collections::HashMap; + + /// Create a test user + pub fn create_test_user(id: i64) -> User { + User::new(id, format!("testuser{}", id), format!("test{}@example.com", id)) + } + + /// Create a test track + pub fn create_test_track(id: Uuid) -> Track { + Track::new(id, "Test Track".to_string(), "Test Artist".to_string(), 180) + } + + /// Create a test playlist + pub fn create_test_playlist(id: Uuid, owner_id: Uuid) -> Playlist { + Playlist::new(id, "Test Playlist".to_string(), owner_id) + } + + /// Create a test database configuration + pub fn create_test_database_config() -> DatabaseConfig { + DatabaseConfig::new( + "postgresql://test:test@localhost:5432/test_db".to_string(), + 10 + ) + } + + /// Create a test Redis configuration + pub fn create_test_redis_config() -> RedisConfig { + RedisConfig::new("redis://localhost:6379".to_string()) + } + + /// Create test context for logging + pub fn create_test_context() -> HashMap { + let mut context = HashMap::new(); + context.insert("user_id".to_string(), "123".to_string()); + context.insert("ip".to_string(), "192.168.1.1".to_string()); + context.insert("request_id".to_string(), Uuid::new_v4().to_string()); + context + } +} + +/// Test helpers for common library tests +pub mod helpers { + use veza_common::types::{ApiResponse, PaginationParams, PaginatedResponse}; + + /// Assert that a result is ok + pub fn assert_ok(result: Result) -> T { + result.expect("Expected Ok result") + } + + /// Assert that a result is an error + pub fn assert_err(result: Result) -> E { + result.expect_err("Expected Err result") + } + + /// Assert that two values are equal + pub fn assert_eq_expected( + actual: T, + expected: T, + message: &str, + ) { + assert_eq!( + actual, expected, + "{}: expected {:?}, got {:?}", + message, expected, actual + ); + } + + /// Create a test API response + pub fn create_test_api_response(data: T) -> ApiResponse { + ApiResponse::success(data) + } + + /// Create a test error API response + pub fn create_test_error_response(message: &str) -> ApiResponse { + ApiResponse::error(message.to_string()) + } + + /// Create test pagination parameters + pub fn create_test_pagination(page: u32, limit: u32) -> PaginationParams { + PaginationParams { page, limit } + } + + /// Create test paginated response + pub fn create_test_paginated_response(items: Vec, total: u64) -> PaginatedResponse { + PaginatedResponse::new(items, total, 1, 20) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fixtures::*; + use helpers::*; + use uuid::Uuid; + + #[test] + fn test_common_utilities() { + // Test UUID generation + let uuid1 = generate_uuid(); + let uuid2 = generate_uuid(); + assert_ne!(uuid1, uuid2); + + // Test duration formatting + let formatted = format_duration(125); + assert_eq!(formatted, "2:05"); + + // Test file size formatting + let size = format_file_size(1024); + assert_eq!(size, "1.00 KB"); + } + + #[test] + fn test_validation_utilities() { + // Test email validation + assert!(validate_email("test@example.com")); + assert!(!validate_email("invalid-email")); + + // Test username validation + assert!(validate_username("user123")); + assert!(!validate_username("ab")); // Too short + + // Test password validation + assert!(validate_password("Password123")); + assert!(!validate_password("short")); // Too short + } + + #[test] + fn test_serialization_utilities() { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestData { + name: String, + value: i32, + } + + let data = TestData { + name: "test".to_string(), + value: 42, + }; + + // Test serialization + let json = to_json(&data).unwrap(); + assert!(json.contains("test")); + assert!(json.contains("42")); + + // Test deserialization + let deserialized: TestData = from_json(&json).unwrap(); + assert_eq!(data, deserialized); + } + + #[test] + fn test_date_utilities() { + use chrono::{Utc, Duration, Datelike}; + + // Test timestamp formatting + let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC + let formatted = format_timestamp(timestamp); + assert!(formatted.contains("2021")); + + // Test date parsing + let date_str = "2021-01-01T00:00:00Z"; + let dt = parse_date(date_str).unwrap(); + assert_eq!(dt.year(), 2021); + + // Test relative time + let now = Utc::now(); + let past = now - Duration::hours(2); + let relative = format_relative_time(&past, &now); + assert!(relative.contains("2")); + assert!(relative.contains("hour")); + } + + #[test] + fn test_logging_utilities() { + // Test log message formatting + let message = format_log_message("api", "INFO", "Test message"); + assert!(message.contains("api")); + assert!(message.contains("INFO")); + assert!(message.contains("Test message")); + + // Test structured log entry + let entry = StructuredLogEntry::new("api", "INFO", "Test message") + .with_context("user_id".to_string(), "123".to_string()); + + let json = entry.to_json(); + assert!(json.contains("api")); + assert!(json.contains("user_id")); + assert!(json.contains("123")); + } + + #[test] + fn test_type_utilities() { + // Test User + let user = create_test_user(1); + assert_eq!(user.id, 1); + assert_eq!(user.username, "testuser1"); + assert!(user.validate().is_ok()); + + // Test Track + let track_id = Uuid::new_v4(); + let track = create_test_track(track_id); + assert_eq!(track.id, track_id); + assert_eq!(track.title, "Test Track"); + assert!(track.validate().is_ok()); + + // Test Playlist + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let playlist = create_test_playlist(playlist_id, owner_id); + assert_eq!(playlist.id, playlist_id); + assert_eq!(playlist.owner_id, owner_id); + assert!(playlist.validate().is_ok()); + } + + #[test] + fn test_config_utilities() { + // Test DatabaseConfig + let db_config = create_test_database_config(); + assert!(db_config.validate().is_ok()); + assert_eq!(db_config.host(), Some("localhost".to_string())); + assert_eq!(db_config.port(), Some(5432)); + + // Test RedisConfig + let redis_config = create_test_redis_config(); + assert!(redis_config.validate().is_ok()); + assert_eq!(redis_config.host(), Some("localhost".to_string())); + assert_eq!(redis_config.port(), Some(6379)); + } + + #[test] + fn test_api_response_utilities() { + // Test success response + let response = create_test_api_response("test data"); + assert!(response.success); + assert!(response.data.is_some()); + assert_eq!(response.data.unwrap(), "test data"); + + // Test error response + let error_response = create_test_error_response("Test error"); + assert!(!error_response.success); + assert!(error_response.data.is_none()); + assert_eq!(error_response.error, Some("Test error".to_string())); + } + + #[test] + fn test_pagination_utilities() { + // Test pagination parameters + let params = create_test_pagination(2, 50); + assert_eq!(params.page, 2); + assert_eq!(params.limit, 50); + + // Test paginated response + let items = vec![1, 2, 3, 4, 5]; + let response = create_test_paginated_response(items.clone(), 100); + assert_eq!(response.items.len(), 5); + assert_eq!(response.total, 100); + assert_eq!(response.total_pages, 5); // 100 / 20 + } + + #[test] + fn test_error_handling() { + // Test CommonError + let error = CommonError::NotFound("Resource not found".to_string()); + assert_eq!(error.code(), "NOT_FOUND"); + assert_eq!(error.http_status_code(), 404); + assert_eq!(error.message(), "Resource not found"); + + // Test ErrorResponse + let error_response: ErrorResponse = (&error).into(); + assert_eq!(error_response.code, "NOT_FOUND"); + assert_eq!(error_response.status, 404); + } + + #[test] + fn test_validation_result() { + // Test validation with Result + assert!(validate_email_result("test@example.com").is_ok()); + assert!(validate_email_result("invalid-email").is_err()); + + assert!(validate_username_result("user123").is_ok()); + assert!(validate_username_result("ab").is_err()); + } + + #[test] + fn test_config_validation() { + // Test DatabaseConfig validation + let mut db_config = create_test_database_config(); + assert!(db_config.validate().is_ok()); + + // Test invalid URL + db_config.url = "invalid-url".to_string(); + assert!(db_config.validate().is_err()); + + // Test RedisConfig validation + let mut redis_config = create_test_redis_config(); + assert!(redis_config.validate().is_ok()); + + // Test invalid URL + redis_config.url = "invalid-url".to_string(); + assert!(redis_config.validate().is_err()); + } + + #[test] + fn test_playlist_operations() { + let playlist_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let track_id = Uuid::new_v4(); + + let mut playlist = create_test_playlist(playlist_id, owner_id); + + // Test adding tracks + playlist.add_track(track_id); + assert_eq!(playlist.track_count(), 1); + assert!(!playlist.is_empty()); + + // Test removing tracks + playlist.remove_track(track_id); + assert_eq!(playlist.track_count(), 0); + assert!(playlist.is_empty()); + } + + #[test] + fn test_track_utilities() { + let track_id = Uuid::new_v4(); + let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 125); + + // Test formatted duration + assert_eq!(track.formatted_duration(), "2:05"); + + // Test validation + assert!(track.validate().is_ok()); + } + + #[test] + fn test_helper_functions() { + // Test assert_ok + let ok_result: Result = Ok(42); + let value = assert_ok(ok_result); + assert_eq!(value, 42); + + // Test assert_err + let err_result: Result = Err("Error".to_string()); + let error = assert_err(err_result); + assert_eq!(error, "Error"); + + // Test assert_eq_expected + assert_eq_expected(5, 5, "Values should be equal"); + } + + #[test] + fn test_context_creation() { + let context = create_test_context(); + assert!(context.contains_key("user_id")); + assert!(context.contains_key("ip")); + assert!(context.contains_key("request_id")); + } + + #[test] + fn test_round_trip_serialization() { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + id: i32, + name: String, + active: bool, + } + + let original = TestStruct { + id: 1, + name: "Test".to_string(), + active: true, + }; + + // Serialize + let json = to_json(&original).unwrap(); + + // Deserialize + let deserialized: TestStruct = from_json(&json).unwrap(); + + assert_eq!(original, deserialized); + } + + #[test] + fn test_config_url_parsing() { + // Test database URL parsing + let db_config = DatabaseConfig::new( + "postgresql://user:pass@localhost:5433/mydb?sslmode=require".to_string(), + 10 + ); + assert_eq!(db_config.host(), Some("localhost".to_string())); + assert_eq!(db_config.port(), Some(5433)); + assert_eq!(db_config.database_name(), Some("mydb".to_string())); + + // Test Redis URL parsing + let redis_config = RedisConfig::new("redis://user:pass@localhost:6380/1".to_string()); + assert_eq!(redis_config.host(), Some("localhost".to_string())); + assert_eq!(redis_config.port(), Some(6380)); + } +} +