diff --git a/Cargo.lock b/Cargo.lock index c05915b..2ce068c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,6 +460,7 @@ dependencies = [ "chrono", "futures", "serde", + "serde_json", "thiserror", "uuid", ] diff --git a/crates/adapters/auth/src/jwt.rs b/crates/adapters/auth/src/jwt.rs index aaff31c..87d047c 100644 --- a/crates/adapters/auth/src/jwt.rs +++ b/crates/adapters/auth/src/jwt.rs @@ -1,9 +1,8 @@ use async_trait::async_trait; use chrono::Utc; -use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}}; +use domain::{errors::DomainError, ports::TokenIssuer, value_objects::SystemId}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; -use std::str::FromStr; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { @@ -30,7 +29,7 @@ impl JwtTokenIssuer { #[async_trait] impl TokenIssuer for JwtTokenIssuer { - async fn issue(&self, user_id: &UserId, role: &Role) -> Result { + async fn issue(&self, user_id: &SystemId, role: &str) -> Result { let claims = Claims { sub: user_id.to_string(), role: role.to_string(), @@ -40,14 +39,12 @@ impl TokenIssuer for JwtTokenIssuer { .map_err(|e| DomainError::Internal(e.to_string())) } - async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> { + async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError> { let data = decode::(token, &self.decoding_key, &Validation::default()) .map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?; let uuid = uuid::Uuid::parse_str(&data.claims.sub) .map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?; - let role = Role::from_str(&data.claims.role) - .map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?; - Ok((UserId::from_uuid(uuid), role)) + Ok((SystemId::from_uuid(uuid), data.claims.role)) } } @@ -58,11 +55,11 @@ mod tests { #[tokio::test] async fn issue_and_verify_roundtrip() { let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!"); - let user_id = UserId::new(); - let token = issuer.issue(&user_id, &Role::User).await.unwrap(); + let user_id = SystemId::new(); + let token = issuer.issue(&user_id, "user").await.unwrap(); let (verified_id, verified_role) = issuer.verify(&token).await.unwrap(); assert_eq!(verified_id, user_id); - assert_eq!(verified_role, Role::User); + assert_eq!(verified_role, "user"); } #[tokio::test] diff --git a/crates/adapters/postgres/src/user_repository.rs b/crates/adapters/postgres/src/user_repository.rs index fb5e2be..bd8db4f 100644 --- a/crates/adapters/postgres/src/user_repository.rs +++ b/crates/adapters/postgres/src/user_repository.rs @@ -3,9 +3,8 @@ use domain::{ entities::User, errors::DomainError, ports::UserRepository, - value_objects::{Email, PasswordHash, Role, UserId}, + value_objects::{Email, PasswordHash, SystemId}, }; -use std::str::FromStr; use crate::db::PgPool; pub struct PostgresUserRepository { @@ -18,9 +17,9 @@ impl PostgresUserRepository { #[async_trait] impl UserRepository for PostgresUserRepository { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { let row = sqlx::query!( - "SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1", + "SELECT id, email, password_hash, created_at FROM users WHERE id = $1", *id.as_uuid() ) .fetch_optional(&self.pool) @@ -28,10 +27,9 @@ impl UserRepository for PostgresUserRepository { .map_err(|e| DomainError::Internal(e.to_string()))?; row.map(|r| Ok(User { - id: UserId::from_uuid(r.id), + id: SystemId::from_uuid(r.id), email: Email::new(r.email)?, password_hash: PasswordHash::from_hash(r.password_hash), - role: Role::from_str(&r.role).map_err(DomainError::Internal)?, created_at: r.created_at, })) .transpose() @@ -39,7 +37,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_email(&self, email: &Email) -> Result, DomainError> { let row = sqlx::query!( - "SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1", + "SELECT id, email, password_hash, created_at FROM users WHERE email = $1", email.as_str() ) .fetch_optional(&self.pool) @@ -47,10 +45,9 @@ impl UserRepository for PostgresUserRepository { .map_err(|e| DomainError::Internal(e.to_string()))?; row.map(|r| Ok(User { - id: UserId::from_uuid(r.id), + id: SystemId::from_uuid(r.id), email: Email::new(r.email)?, password_hash: PasswordHash::from_hash(r.password_hash), - role: Role::from_str(&r.role).map_err(DomainError::Internal)?, created_at: r.created_at, })) .transpose() @@ -58,16 +55,14 @@ impl UserRepository for PostgresUserRepository { async fn save(&self, user: &User) -> Result<(), DomainError> { sqlx::query!( - "INSERT INTO users (id, email, password_hash, role, created_at) - VALUES ($1, $2, $3, $4, $5) + "INSERT INTO users (id, email, password_hash, created_at) + VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, - password_hash = EXCLUDED.password_hash, - role = EXCLUDED.role", + password_hash = EXCLUDED.password_hash", *user.id.as_uuid(), user.email.as_str(), user.password_hash.as_str(), - user.role.to_string(), user.created_at ) .execute(&self.pool) @@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository { Ok(()) } - async fn delete(&self, id: &UserId) -> Result<(), DomainError> { + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid()) .execute(&self.pool) .await diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index 0a9f7ee..3c11ac4 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -5,7 +5,6 @@ use uuid::Uuid; pub struct UserResponse { pub id: Uuid, pub email: String, - pub role: String, pub created_at: DateTime, } @@ -20,7 +19,6 @@ impl UserResponse { Self { id: *user.id.as_uuid(), email: user.email.to_string(), - role: user.role.to_string(), created_at: user.created_at, } } diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs index 1d27ddd..90b286e 100644 --- a/crates/application/src/testing.rs +++ b/crates/application/src/testing.rs @@ -5,7 +5,7 @@ use domain::{ entities::User, errors::DomainError, ports::{PasswordHasher, TokenIssuer, UserRepository}, - value_objects::{Email, PasswordHash, Role, UserId}, + value_objects::{Email, PasswordHash, SystemId}, }; pub struct InMemoryUserRepository { @@ -28,7 +28,7 @@ impl Default for InMemoryUserRepository { #[async_trait] impl UserRepository for InMemoryUserRepository { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { Ok(self.users.lock().await.get(&id.to_string()).cloned()) } @@ -43,7 +43,7 @@ impl UserRepository for InMemoryUserRepository { Ok(()) } - async fn delete(&self, id: &UserId) -> Result<(), DomainError> { + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { self.users.lock().await.remove(&id.to_string()); Ok(()) } @@ -65,15 +65,15 @@ pub struct StubTokenIssuer; #[async_trait] impl TokenIssuer for StubTokenIssuer { - async fn issue(&self, user_id: &UserId, _role: &Role) -> Result { + async fn issue(&self, user_id: &SystemId, _role: &str) -> Result { Ok(format!("token:{user_id}")) } - async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> { + async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError> { let id_str = token.strip_prefix("token:").ok_or_else(|| { DomainError::Unauthorized("Invalid stub token".to_string()) })?; let uuid = uuid::Uuid::parse_str(id_str) .map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?; - Ok((UserId::from_uuid(uuid), Role::User)) + Ok((SystemId::from_uuid(uuid), "user".to_string())) } } diff --git a/crates/application/src/use_cases/get_profile.rs b/crates/application/src/use_cases/get_profile.rs index 1d7c687..1cf11c3 100644 --- a/crates/application/src/use_cases/get_profile.rs +++ b/crates/application/src/use_cases/get_profile.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId}; +use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::SystemId}; pub struct GetProfile { repo: Arc, @@ -8,7 +8,7 @@ pub struct GetProfile { impl GetProfile { pub fn new(repo: Arc) -> Self { Self { repo } } - pub async fn execute(&self, user_id: &UserId) -> Result { + pub async fn execute(&self, user_id: &SystemId) -> Result { self.repo.find_by_id(user_id).await? .ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found"))) } @@ -34,7 +34,7 @@ mod tests { async fn get_profile_returns_not_found() { let repo = Arc::new(InMemoryUserRepository::new()); let uc = GetProfile::new(repo); - let result = uc.execute(&UserId::new()).await; + let result = uc.execute(&SystemId::new()).await; assert!(matches!(result, Err(DomainError::NotFound(_)))); } } diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs index fb3355f..e4ed15b 100644 --- a/crates/application/src/use_cases/login.rs +++ b/crates/application/src/use_cases/login.rs @@ -29,7 +29,7 @@ impl LoginUser { if !valid { return Err(DomainError::Unauthorized("Invalid credentials".to_string())); } - let token = self.issuer.issue(&user.id, &user.role).await?; + let token = self.issuer.issue(&user.id, "user").await?; Ok((user, token)) } } diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs index 341b40a..1a5e21e 100644 --- a/crates/application/src/use_cases/register.rs +++ b/crates/application/src/use_cases/register.rs @@ -3,7 +3,7 @@ use domain::{ entities::User, errors::DomainError, ports::{PasswordHasher, UserRepository}, - value_objects::{Email, UserId}, + value_objects::{Email, SystemId}, }; pub struct RegisterUser { @@ -25,7 +25,7 @@ impl RegisterUser { return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str()))); } let hash = self.hasher.hash(password).await?; - let user = User::new(UserId::new(), email, hash); + let user = User::new(SystemId::new(), email, hash); self.repo.save(&user).await?; Ok(user) } diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index fc590b6..90ddc68 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -11,3 +11,6 @@ thiserror = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } futures = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs index 3eae318..6e07b7b 100644 --- a/crates/domain/src/entities/user.rs +++ b/crates/domain/src/entities/user.rs @@ -1,17 +1,16 @@ use chrono::{DateTime, Utc}; -use crate::value_objects::{Email, PasswordHash, Role, UserId}; +use crate::value_objects::{Email, PasswordHash, SystemId}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct User { - pub id: UserId, + pub id: SystemId, pub email: Email, pub password_hash: PasswordHash, - pub role: Role, pub created_at: DateTime, } impl User { - pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self { - Self { id, email, password_hash, role: Role::User, created_at: Utc::now() } + pub fn new(id: SystemId, email: Email, password_hash: PasswordHash) -> Self { + Self { id, email, password_hash, created_at: Utc::now() } } } diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index c93f0e9..6222ba1 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}}; +use crate::{errors::DomainError, value_objects::{PasswordHash, SystemId}}; #[async_trait] pub trait PasswordHasher: Send + Sync { @@ -9,6 +9,6 @@ pub trait PasswordHasher: Send + Sync { #[async_trait] pub trait TokenIssuer: Send + Sync { - async fn issue(&self, user_id: &UserId, role: &Role) -> Result; - async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>; + async fn issue(&self, user_id: &SystemId, role: &str) -> Result; + async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>; } diff --git a/crates/domain/src/ports/user_repo.rs b/crates/domain/src/ports/user_repo.rs index 07dc7b8..5ea8db8 100644 --- a/crates/domain/src/ports/user_repo.rs +++ b/crates/domain/src/ports/user_repo.rs @@ -1,10 +1,10 @@ use async_trait::async_trait; -use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}}; +use crate::{entities::User, errors::DomainError, value_objects::{Email, SystemId}}; #[async_trait] pub trait UserRepository: Send + Sync { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>; - async fn delete(&self, id: &UserId) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; } diff --git a/crates/domain/src/value_objects/checksum.rs b/crates/domain/src/value_objects/checksum.rs new file mode 100644 index 0000000..25a5411 --- /dev/null +++ b/crates/domain/src/value_objects/checksum.rs @@ -0,0 +1,29 @@ +use crate::errors::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct Checksum(String); + +impl Checksum { + pub fn new(hex: impl Into) -> Result { + let hex = hex.into().to_lowercase(); + if hex.len() != 64 { + return Err(DomainError::Validation( + format!("Checksum must be 64 hex characters, got {}", hex.len()), + )); + } + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(DomainError::Validation( + "Checksum contains non-hex characters".to_string(), + )); + } + Ok(Self(hex)) + } + + pub fn as_str(&self) -> &str { &self.0 } +} + +impl std::fmt::Display for Checksum { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/domain/src/value_objects/date_time_stamp.rs b/crates/domain/src/value_objects/date_time_stamp.rs new file mode 100644 index 0000000..506139e --- /dev/null +++ b/crates/domain/src/value_objects/date_time_stamp.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct DateTimeStamp(DateTime); + +impl DateTimeStamp { + pub fn now() -> Self { Self(Utc::now()) } + pub fn from_datetime(dt: DateTime) -> Self { Self(dt) } + pub fn as_datetime(&self) -> &DateTime { &self.0 } +} + +impl std::fmt::Display for DateTimeStamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.to_rfc3339()) + } +} + +impl From> for DateTimeStamp { + fn from(dt: DateTime) -> Self { Self(dt) } +} diff --git a/crates/domain/src/value_objects/email.rs b/crates/domain/src/value_objects/email.rs index 87898a3..6f96a61 100644 --- a/crates/domain/src/value_objects/email.rs +++ b/crates/domain/src/value_objects/email.rs @@ -21,22 +21,3 @@ impl std::fmt::Display for Email { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_empty() { assert!(Email::new("").is_err()); } - - #[test] - fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); } - - #[test] - fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); } - - #[test] - fn lowercases_and_trims() { - let email = Email::new(" User@Example.Com ").unwrap(); - assert_eq!(email.as_str(), "user@example.com"); - } -} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 4624615..3f5df93 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,9 +1,13 @@ +mod checksum; +mod date_time_stamp; mod email; mod password; -mod role; -mod user_id; +mod structured_data; +mod system_id; +pub use checksum::Checksum; +pub use date_time_stamp::DateTimeStamp; pub use email::Email; pub use password::PasswordHash; -pub use role::Role; -pub use user_id::UserId; +pub use structured_data::{MetadataValue, StructuredData}; +pub use system_id::SystemId; diff --git a/crates/domain/src/value_objects/role.rs b/crates/domain/src/value_objects/role.rs deleted file mode 100644 index c4efe7b..0000000 --- a/crates/domain/src/value_objects/role.rs +++ /dev/null @@ -1,23 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Role { User, Admin } - -impl std::fmt::Display for Role { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Role::User => write!(f, "user"), - Role::Admin => write!(f, "admin"), - } - } -} - -impl std::str::FromStr for Role { - type Err = String; - fn from_str(s: &str) -> Result { - match s { - "user" => Ok(Role::User), - "admin" => Ok(Role::Admin), - other => Err(format!("Unknown role: {other}")), - } - } -} diff --git a/crates/domain/src/value_objects/structured_data.rs b/crates/domain/src/value_objects/structured_data.rs new file mode 100644 index 0000000..0730b37 --- /dev/null +++ b/crates/domain/src/value_objects/structured_data.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum MetadataValue { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + Null, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StructuredData(HashMap); + +impl StructuredData { + pub fn new() -> Self { Self(HashMap::new()) } + + pub fn insert(&mut self, key: impl Into, value: MetadataValue) { + self.0.insert(key.into(), value); + } + + pub fn get(&self, key: &str) -> Option<&MetadataValue> { + self.0.get(key) + } + + pub fn get_string(&self, key: &str) -> Option<&str> { + match self.0.get(key) { + Some(MetadataValue::String(s)) => Some(s.as_str()), + _ => None, + } + } + + pub fn keys(&self) -> impl Iterator { + self.0.keys() + } + + pub fn is_empty(&self) -> bool { self.0.is_empty() } + pub fn len(&self) -> usize { self.0.len() } + + pub fn merge_from(&mut self, other: StructuredData) { + self.0.extend(other.0); + } + + pub fn remove(&mut self, key: &str) -> Option { + self.0.remove(key) + } + + pub fn inner(&self) -> &HashMap { &self.0 } +} + +impl Default for StructuredData { + fn default() -> Self { Self::new() } +} diff --git a/crates/domain/src/value_objects/system_id.rs b/crates/domain/src/value_objects/system_id.rs new file mode 100644 index 0000000..126a691 --- /dev/null +++ b/crates/domain/src/value_objects/system_id.rs @@ -0,0 +1,24 @@ +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SystemId(Uuid); + +impl SystemId { + pub fn new() -> Self { Self(Uuid::new_v4()) } + pub fn from_uuid(id: Uuid) -> Self { Self(id) } + pub fn as_uuid(&self) -> &Uuid { &self.0 } +} + +impl Default for SystemId { + fn default() -> Self { Self::new() } +} + +impl std::fmt::Display for SystemId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for SystemId { + fn from(id: Uuid) -> Self { Self(id) } +} diff --git a/crates/domain/src/value_objects/user_id.rs b/crates/domain/src/value_objects/user_id.rs deleted file mode 100644 index 0d30683..0000000 --- a/crates/domain/src/value_objects/user_id.rs +++ /dev/null @@ -1,22 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct UserId(uuid::Uuid); - -impl UserId { - pub fn new() -> Self { Self(uuid::Uuid::new_v4()) } - pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) } - pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 } -} - -impl Default for UserId { - fn default() -> Self { Self::new() } -} - -impl std::fmt::Display for UserId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for UserId { - fn from(id: uuid::Uuid) -> Self { Self(id) } -} diff --git a/crates/domain/tests/domain_tests.rs b/crates/domain/tests/domain_tests.rs new file mode 100644 index 0000000..f696642 --- /dev/null +++ b/crates/domain/tests/domain_tests.rs @@ -0,0 +1 @@ +mod value_objects; diff --git a/crates/domain/tests/value_objects/checksum.rs b/crates/domain/tests/value_objects/checksum.rs new file mode 100644 index 0000000..fcac8bd --- /dev/null +++ b/crates/domain/tests/value_objects/checksum.rs @@ -0,0 +1,27 @@ +use domain::value_objects::Checksum; + +#[test] +fn accepts_valid_sha256() { + let hex = "a".repeat(64); + assert!(Checksum::new(&hex).is_ok()); +} + +#[test] +fn rejects_short() { + let hex = "a".repeat(63); + assert!(Checksum::new(&hex).is_err()); +} + +#[test] +fn rejects_non_hex() { + let mut hex = "a".repeat(63); + hex.push('g'); + assert!(Checksum::new(&hex).is_err()); +} + +#[test] +fn normalizes_to_lowercase() { + let hex = "A".repeat(64); + let cs = Checksum::new(&hex).unwrap(); + assert_eq!(cs.as_str(), "a".repeat(64)); +} diff --git a/crates/domain/tests/value_objects/date_time_stamp.rs b/crates/domain/tests/value_objects/date_time_stamp.rs new file mode 100644 index 0000000..ce0e9a9 --- /dev/null +++ b/crates/domain/tests/value_objects/date_time_stamp.rs @@ -0,0 +1,30 @@ +use chrono::Utc; +use domain::value_objects::DateTimeStamp; + +#[test] +fn now_is_recent() { + let before = Utc::now(); + let ts = DateTimeStamp::now(); + let after = Utc::now(); + assert!(*ts.as_datetime() >= before); + assert!(*ts.as_datetime() <= after); +} + +#[test] +fn ordering() { + let a = DateTimeStamp::from_datetime( + chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z").unwrap().into(), + ); + let b = DateTimeStamp::from_datetime( + chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z").unwrap().into(), + ); + assert!(a < b); +} + +#[test] +fn serde_roundtrip() { + let ts = DateTimeStamp::now(); + let json = serde_json::to_string(&ts).unwrap(); + let back: DateTimeStamp = serde_json::from_str(&json).unwrap(); + assert_eq!(ts, back); +} diff --git a/crates/domain/tests/value_objects/mod.rs b/crates/domain/tests/value_objects/mod.rs new file mode 100644 index 0000000..01fcb64 --- /dev/null +++ b/crates/domain/tests/value_objects/mod.rs @@ -0,0 +1,4 @@ +mod checksum; +mod date_time_stamp; +mod structured_data; +mod system_id; diff --git a/crates/domain/tests/value_objects/structured_data.rs b/crates/domain/tests/value_objects/structured_data.rs new file mode 100644 index 0000000..dfbf953 --- /dev/null +++ b/crates/domain/tests/value_objects/structured_data.rs @@ -0,0 +1,45 @@ +use domain::value_objects::{MetadataValue, StructuredData}; + +#[test] +fn insert_and_get() { + let mut sd = StructuredData::new(); + sd.insert("key", MetadataValue::String("value".into())); + assert_eq!(sd.get_string("key"), Some("value")); + assert_eq!(sd.len(), 1); + assert!(!sd.is_empty()); +} + +#[test] +fn merge_from_overwrites() { + let mut a = StructuredData::new(); + a.insert("k", MetadataValue::Integer(1)); + + let mut b = StructuredData::new(); + b.insert("k", MetadataValue::Integer(2)); + + a.merge_from(b); + assert!(matches!(a.get("k"), Some(MetadataValue::Integer(2)))); +} + +#[test] +fn serde_roundtrip() { + let mut sd = StructuredData::new(); + sd.insert("s", MetadataValue::String("hello".into())); + sd.insert("i", MetadataValue::Integer(42)); + sd.insert("f", MetadataValue::Float(3.14)); + sd.insert("b", MetadataValue::Boolean(true)); + sd.insert("n", MetadataValue::Null); + + let json = serde_json::to_string(&sd).unwrap(); + let back: StructuredData = serde_json::from_str(&json).unwrap(); + assert_eq!(sd, back); +} + +#[test] +fn remove_works() { + let mut sd = StructuredData::new(); + sd.insert("k", MetadataValue::Integer(1)); + let removed = sd.remove("k"); + assert!(matches!(removed, Some(MetadataValue::Integer(1)))); + assert!(sd.is_empty()); +} diff --git a/crates/domain/tests/value_objects/system_id.rs b/crates/domain/tests/value_objects/system_id.rs new file mode 100644 index 0000000..24bb2c3 --- /dev/null +++ b/crates/domain/tests/value_objects/system_id.rs @@ -0,0 +1,31 @@ +use domain::value_objects::SystemId; +use uuid::Uuid; + +#[test] +fn unique_generation() { + let a = SystemId::new(); + let b = SystemId::new(); + assert_ne!(a, b); +} + +#[test] +fn uuid_roundtrip() { + let uuid = Uuid::new_v4(); + let id = SystemId::from_uuid(uuid); + assert_eq!(*id.as_uuid(), uuid); +} + +#[test] +fn display_matches_uuid() { + let uuid = Uuid::new_v4(); + let id = SystemId::from_uuid(uuid); + assert_eq!(id.to_string(), uuid.to_string()); +} + +#[test] +fn serde_roundtrip() { + let id = SystemId::new(); + let json = serde_json::to_string(&id).unwrap(); + let back: SystemId = serde_json::from_str(&json).unwrap(); + assert_eq!(id, back); +} diff --git a/crates/presentation/src/extractors/auth.rs b/crates/presentation/src/extractors/auth.rs index 7fd01c6..22a4dfd 100644 --- a/crates/presentation/src/extractors/auth.rs +++ b/crates/presentation/src/extractors/auth.rs @@ -4,13 +4,13 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use domain::value_objects::{Role, UserId}; +use domain::value_objects::SystemId; use serde_json::json; use crate::state::AppState; pub struct JwtClaims { - pub user_id: UserId, - pub role: Role, + pub user_id: SystemId, + pub role: String, } impl FromRequestParts for JwtClaims { diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 8da74f2..f60da05 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -19,7 +19,7 @@ pub async fn register( ValidatedJson(req): ValidatedJson, ) -> Result<(StatusCode, Json), AppError> { let user = state.register_uc.execute(&req.email, &req.password).await?; - let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?; + let token = state.token_issuer.issue(&user.id, "user").await.map_err(AppError::from)?; Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))) }