diff --git a/crates/backend/src/controller.rs b/crates/backend/src/controller.rs index ee8e8a5..b5e90a0 100644 --- a/crates/backend/src/controller.rs +++ b/crates/backend/src/controller.rs @@ -1,6 +1,7 @@ use actix_web::web::{self, ServiceConfig}; -pub mod auth; // TODO: Refactor to use re-exports instead of making module public +// TODO: Refactor to use re-exports instead of making module public +pub mod auth; pub mod class; pub mod group; pub mod project; @@ -15,6 +16,6 @@ pub fn register_controllers(cfg: &mut ServiceConfig) { .service(web::scope("/template").configure(template::setup)) .service(web::scope("/auth").configure(auth::setup)) .service( - web::resource("ok").to(|| async { actix_web::HttpResponse::Ok().body("available") }), + web::resource("/ok").to(|| async { actix_web::HttpResponse::Ok().body("available") }), ); } diff --git a/crates/backend/src/controller/class.rs b/crates/backend/src/controller/class.rs index 9f9343c..591cdfa 100644 --- a/crates/backend/src/controller/class.rs +++ b/crates/backend/src/controller/class.rs @@ -1,4 +1,4 @@ -use actix_web::{delete, get, post, put, Responder}; +use actix_web::{Responder, delete, get, post, put}; pub fn setup(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_classes) diff --git a/crates/backend/src/controller/group.rs b/crates/backend/src/controller/group.rs index e125764..8df024d 100644 --- a/crates/backend/src/controller/group.rs +++ b/crates/backend/src/controller/group.rs @@ -1,4 +1,4 @@ -use actix_web::{delete, get, post, put, Responder}; +use actix_web::{Responder, delete, get, post, put}; pub fn setup(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_groups) diff --git a/crates/backend/src/controller/project.rs b/crates/backend/src/controller/project.rs index 476e954..0fc6794 100644 --- a/crates/backend/src/controller/project.rs +++ b/crates/backend/src/controller/project.rs @@ -1,10 +1,10 @@ -use actix_web::{delete, get, post, put, web, Result}; +use actix_web::{Result, delete, get, post, put, web}; use uuid::Uuid; use validator::Validate; -use crate::db::project::CreateProject; use crate::db::Database; -use crate::entity; +use crate::db::entity; +use crate::db::project::CreateProject; use crate::error::ApiError; pub fn setup(cfg: &mut actix_web::web::ServiceConfig) { diff --git a/crates/backend/src/controller/template.rs b/crates/backend/src/controller/template.rs index 61b138d..e078b00 100644 --- a/crates/backend/src/controller/template.rs +++ b/crates/backend/src/controller/template.rs @@ -1,4 +1,4 @@ -use actix_web::{delete, get, post, put, Responder}; +use actix_web::{Responder, delete, get, post, put}; pub fn setup(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_templates) diff --git a/crates/backend/src/controller/user.rs b/crates/backend/src/controller/user.rs index b2b3247..3adee87 100644 --- a/crates/backend/src/controller/user.rs +++ b/crates/backend/src/controller/user.rs @@ -1,4 +1,4 @@ -use crate::{Database, entity, error::ApiError}; +use crate::{Database, db::entity, error::ApiError}; use actix_web::{Responder, delete, get, post, put, web}; use serde::Deserialize; use utoipa::ToSchema; @@ -15,6 +15,7 @@ pub fn setup(cfg: &mut actix_web::web::ServiceConfig) { pub struct CreateUser { #[validate(length(min = 4, max = 255))] /// Username (minimum 4 characters, maximum 255 characters) + /// TODO: Don't allow spaces, only alphanumeric characters and underscores username: String, #[validate(length(min = 3))] /// Full name of the user (minimum 3 characters) @@ -78,10 +79,12 @@ async fn get_user( responses( (status = 200, description = "User created successfully", body = entity::user::Model, content_type = "application/json"), (status = 400, description = "Invalid request data or validation error", body = String, content_type = "application/json"), + (status = 409, description = "User already exists", body = String, content_type = "application/json"), (status = 500, description = "Internal server error", body = String, content_type = "application/json") ) )] #[post("")] +// TODO: if a user with the same username already exists, return 409 Conflict async fn create_user( db: web::Data, user: web::Json, diff --git a/crates/backend/src/db/project.rs b/crates/backend/src/db/project.rs index 23df789..e5c453e 100644 --- a/crates/backend/src/db/project.rs +++ b/crates/backend/src/db/project.rs @@ -2,7 +2,7 @@ use super::Database; use crate::error::ApiError; use log::debug; -use crate::entity::project; +use crate::db::entity::project; use sea_orm::ActiveValue::{NotSet, Set, Unchanged}; use sea_orm::{ActiveModelTrait, DeleteResult, EntityTrait}; use serde::Deserialize; diff --git a/crates/backend/src/db/user.rs b/crates/backend/src/db/user.rs index 4e5cf28..8aaf9cc 100644 --- a/crates/backend/src/db/user.rs +++ b/crates/backend/src/db/user.rs @@ -10,7 +10,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::{Database, entity}; +use crate::{Database, db::entity}; impl Database { pub async fn get_users(&self) -> Result, ApiError> { diff --git a/crates/backend/src/error.rs b/crates/backend/src/error.rs index e34cbba..50f0872 100644 --- a/crates/backend/src/error.rs +++ b/crates/backend/src/error.rs @@ -66,4 +66,3 @@ impl MessageResponse { } } } - diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index d36f5c5..b9d201e 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -1,39 +1,9 @@ pub mod controller; pub mod db; pub mod error; +pub mod utils; pub mod utoipa; pub use db::Database; pub use db::entity; - -use dotenvy; -use std::env; - -#[cfg(not(test))] -fn get_env_var(name: &str) -> dotenvy::Result { - dotenvy::var(name) -} - -#[cfg(test)] -fn get_env_var(name: &str) -> Result { - std::env::var(name) -} - -// TODO: Extract build_database_url into a utils module or similar -pub fn build_database_url() -> String { - let db_user = get_env_var("DB_USER").unwrap_or_else(|_| "pgg".to_owned()); - let db_name = get_env_var("DB_NAME").unwrap_or_else(|_| "pgg".to_owned()); - let db_password = get_env_var("DB_PASSWORD").unwrap_or_else(|_| "pgg".to_owned()); - let db_host = get_env_var("DB_HOST").expect("DB_HOST must be set in .env"); - let db_port = get_env_var("DB_PORT") - .map(|x| x.parse::().expect("DB_PORT is not a valid port")) - .unwrap_or(5432); - - let result = format!( - "postgresql://{}:{}@{}:{}/{}", - db_user, db_password, db_host, db_port, db_name - ); - - println!("Database URL: {}", result); - result -} +pub use utils::{build_database_url, get_env_var}; diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index eee7606..866fef1 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -7,13 +7,14 @@ use utoipa_swagger_ui::SwaggerUi; mod controller; mod db; mod error; +mod utils; mod utoipa; -pub use db::Database; -pub use db::entity; +use db::Database; use log::info; use migration::Migrator; use migration::MigratorTrait; +use utils::{build_database_url, get_env_var}; #[derive(Clone)] struct AppConfig { @@ -60,10 +61,10 @@ async fn main() -> std::io::Result<()> { .wrap(Logger::default()) .wrap(session_middleware) .service(web::scope("/api/v1").configure(controller::register_controllers)) - .service( - SwaggerUi::new("/swagger-ui/{_:.*}") - .url("/api-docs/openapi.json", crate::utoipa::ApiDoc::openapi_spec()), - ); + .service(SwaggerUi::new("/swagger-ui/{_:.*}").url( + "/api-docs/openapi.json", + crate::utoipa::ApiDoc::openapi_spec(), + )); #[cfg(feature = "serve")] let app = { @@ -80,16 +81,6 @@ async fn main() -> std::io::Result<()> { .await } -#[cfg(not(test))] -fn get_env_var(name: &str) -> dotenvy::Result { - dotenvy::var(name) -} - -#[cfg(test)] -fn get_env_var(name: &str) -> Result { - std::env::var(name) -} - pub async fn connect_to_redis_database() -> RedisSessionStore { let redis_host = get_env_var("REDIS_HOST").expect("REDIS_HOST must be set in .env"); let redis_port = get_env_var("REDIS_PORT") @@ -102,24 +93,6 @@ pub async fn connect_to_redis_database() -> RedisSessionStore { .unwrap() } -pub fn build_database_url() -> String { - let db_user = get_env_var("DB_USER").unwrap_or_else(|_| "pgg".to_owned()); - let db_name = get_env_var("DB_NAME").unwrap_or_else(|_| "pgg".to_owned()); - let db_password = get_env_var("DB_PASSWORD").unwrap_or_else(|_| "pgg".to_owned()); - let db_host = get_env_var("DB_HOST").expect("DB_HOST must be set in .env"); - let db_port = get_env_var("DB_PORT") - .map(|x| x.parse::().expect("DB_PORT is not a valid port")) - .unwrap_or(5432); - - let result = format!( - "postgresql://{}:{}@{}:{}/{}", - db_user, db_password, db_host, db_port, db_name - ); - - println!("Database URL: {}", result); - result -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/backend/src/utils.rs b/crates/backend/src/utils.rs new file mode 100644 index 0000000..efb1e15 --- /dev/null +++ b/crates/backend/src/utils.rs @@ -0,0 +1,29 @@ +use log::info; + +#[cfg(not(test))] +pub fn get_env_var(name: &str) -> dotenvy::Result { + dotenvy::var(name) +} + +#[cfg(test)] +pub fn get_env_var(name: &str) -> Result { + std::env::var(name) +} + +pub fn build_database_url() -> String { + let db_user = get_env_var("DB_USER").unwrap_or_else(|_| "pgg".to_owned()); + let db_name = get_env_var("DB_NAME").unwrap_or_else(|_| "pgg".to_owned()); + let db_password = get_env_var("DB_PASSWORD").unwrap_or_else(|_| "pgg".to_owned()); + let db_host = get_env_var("DB_HOST").expect("DB_HOST must be set in .env"); + let db_port = get_env_var("DB_PORT") + .map(|x| x.parse::().expect("DB_PORT is not a valid port")) + .unwrap_or(5432); + + let result = format!( + "postgresql://{}:{}@{}:{}/{}", + db_user, db_password, db_host, db_port, db_name + ); + + info!("Database URL: {}", result); + result +} diff --git a/crates/backend/src/utoipa.rs b/crates/backend/src/utoipa.rs index 6814dd4..fad342d 100644 --- a/crates/backend/src/utoipa.rs +++ b/crates/backend/src/utoipa.rs @@ -1,6 +1,6 @@ use utoipa::OpenApi; -use crate::{controller, db, entity, error}; +use crate::{controller, db, db::entity, error}; #[derive(OpenApi)] #[openapi( @@ -61,4 +61,4 @@ impl ApiDoc { pub fn openapi_spec() -> utoipa::openapi::OpenApi { Self::openapi() } -} \ No newline at end of file +} diff --git a/crates/backend/tests/common/db_helpers/auth_helpers.rs b/crates/backend/tests/common/db_helpers/auth_helpers.rs new file mode 100644 index 0000000..4bd788f --- /dev/null +++ b/crates/backend/tests/common/db_helpers/auth_helpers.rs @@ -0,0 +1,123 @@ +/* +use crate::common::test_helpers::TestContext; +use backend::{Database, db::entity}; +use uuid::Uuid; + +impl TestContext { + pub async fn create_user_with_auth( + &self, + db: &Database, + username: Option, + name: Option, + password: String, + ) -> Result { + self.create_user_with_password(db, username, name, password) + .await + } + + pub async fn verify_user_login( + &self, + db: &Database, + username: &str, + password: &str, + ) -> Result { + db.verify_local_user(username, password).await + } + + pub async fn create_authenticated_user( + &self, + db: &Database, + username: Option, + password: Option, + ) -> Result<(entity::user::Model, String), backend::error::ApiError> { + let test_id = &self.test_id; + let username = username.unwrap_or_else(|| format!("auth_user_{}", test_id)); + let password = password.unwrap_or_else(|| "test_password_123".to_string()); + + let user = self + .create_user_with_password(db, Some(username.clone()), None, password.clone()) + .await?; + + Ok((user, password)) + } + + pub async fn create_multiple_authenticated_users( + &self, + db: &Database, + count: usize, + ) -> Result, backend::error::ApiError> { + let mut users = Vec::new(); + + for i in 0..count { + let username = format!("auth_user_{}_{}", self.test_id, i); + let password = format!("password_{}", i); + let user_data = self + .create_authenticated_user(db, Some(username), Some(password.clone())) + .await?; + users.push(user_data); + } + + Ok(users) + } + + pub async fn assert_user_can_login( + &self, + db: &Database, + username: &str, + password: &str, + ) -> bool { + match self.verify_user_login(db, username, password).await { + Ok(_) => true, + Err(_) => false, + } + } + + pub async fn assert_user_cannot_login( + &self, + db: &Database, + username: &str, + password: &str, + ) -> bool { + !self.assert_user_can_login(db, username, password).await + } + + pub async fn assert_user_login_returns_correct_id( + &self, + db: &Database, + username: &str, + password: &str, + expected_id: Uuid, + ) -> bool { + match self.verify_user_login(db, username, password).await { + Ok(id) => id == expected_id, + Err(_) => false, + } + } + + pub async fn test_invalid_login_attempts(&self, db: &Database, username: &str) -> Vec { + let invalid_passwords = vec!["wrong_password", "", "123", "password"]; + let mut results = Vec::new(); + + for password in invalid_passwords { + let can_login = self.assert_user_can_login(db, username, password).await; + results.push(!can_login); // We expect these to fail, so invert the result + } + + results + } + + pub async fn create_user_and_verify_auth( + &self, + db: &Database, + username: Option, + password: Option, + ) -> Result<(entity::user::Model, bool), backend::error::ApiError> { + let (user, pwd) = self + .create_authenticated_user(db, username, password) + .await?; + let can_login = self.assert_user_can_login(db, &user.username, &pwd).await; + + Ok((user, can_login)) + } +} +*/ diff --git a/crates/backend/tests/common/db_helpers/mod.rs b/crates/backend/tests/common/db_helpers/mod.rs new file mode 100644 index 0000000..276446e --- /dev/null +++ b/crates/backend/tests/common/db_helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod user_helpers; +pub mod project_helpers; +pub mod auth_helpers; diff --git a/crates/backend/tests/common/db_helpers/project_helpers.rs b/crates/backend/tests/common/db_helpers/project_helpers.rs new file mode 100644 index 0000000..b947447 --- /dev/null +++ b/crates/backend/tests/common/db_helpers/project_helpers.rs @@ -0,0 +1,125 @@ +use crate::common::test_helpers::TestContext; +use backend::{ + Database, + db::{entity, project::CreateProject}, +}; +use uuid::Uuid; + +impl TestContext { + pub async fn create_project( + &self, + db: &Database, + name: Option, + ) -> Result { + let name = name.unwrap_or_else(|| format!("Test Project {}", self.test_id)); + let create_project = CreateProject { name }; + + let project = db.create_project(create_project).await?; + + if let Ok(mut projects) = self.created_projects.lock() { + projects.push(project.id); + } + + Ok(project) + } + + pub async fn create_project_with_name( + &self, + db: &Database, + name: String, + ) -> Result { + self.create_project(db, Some(name)).await + } + + pub async fn create_multiple_projects( + &self, + db: &Database, + count: usize, + ) -> Result, backend::error::ApiError> { + let mut projects = Vec::new(); + + for i in 0..count { + let name = format!("Test Project {} {}", self.test_id, i); + let project = self.create_project(db, Some(name)).await?; + projects.push(project); + } + + Ok(projects) + } + + pub async fn get_project_by_id( + &self, + db: &Database, + id: &Uuid, + ) -> Result, backend::error::ApiError> { + db.get_project(id).await + } + + pub async fn get_all_projects( + &self, + db: &Database, + ) -> Result, backend::error::ApiError> { + db.get_projects().await + } + + pub async fn update_project( + &self, + db: &Database, + id: &Uuid, + name: String, + ) -> Result { + let update_data = CreateProject { name }; + db.update_project(id, update_data).await + } + + pub async fn assert_project_exists(&self, db: &Database, id: &Uuid) -> bool { + match self.get_project_by_id(db, id).await { + Ok(Some(_)) => true, + _ => false, + } + } + + pub async fn assert_project_count(&self, db: &Database, expected: usize) -> bool { + match self.get_all_projects(db).await { + Ok(projects) => projects.len() == expected, + Err(_) => false, + } + } + + pub async fn assert_project_not_exists(&self, db: &Database, id: &Uuid) -> bool { + !self.assert_project_exists(db, id).await + } + + pub async fn assert_project_name(&self, db: &Database, id: &Uuid, expected_name: &str) -> bool { + match self.get_project_by_id(db, id).await { + Ok(Some(project)) => project.name == expected_name, + _ => false, + } + } + + pub async fn delete_project( + &self, + db: &Database, + id: &Uuid, + ) -> Result<(), backend::error::ApiError> { + db.delete_project(id).await?; + + if let Ok(mut projects) = self.created_projects.lock() { + projects.retain(|&project_id| project_id != *id); + } + + Ok(()) + } + + pub async fn cleanup_projects(&self, db: &Database) { + if let Ok(projects) = self.created_projects.lock() { + for project_id in projects.iter() { + let _ = db.delete_project(project_id).await; + } + } + + if let Ok(mut projects) = self.created_projects.lock() { + projects.clear(); + } + } +} diff --git a/crates/backend/tests/common/db_helpers/user_helpers.rs b/crates/backend/tests/common/db_helpers/user_helpers.rs new file mode 100644 index 0000000..bbfc036 --- /dev/null +++ b/crates/backend/tests/common/db_helpers/user_helpers.rs @@ -0,0 +1,99 @@ +use crate::common::test_helpers::TestContext; +use backend::{Database, db::entity}; +use uuid::Uuid; + +impl TestContext { + pub async fn create_user( + &self, + db: &Database, + username: Option, + name: Option, + ) -> Result { + let test_id = &self.test_id; + let username = username.unwrap_or_else(|| format!("user_{}", test_id)); + let name = name.unwrap_or_else(|| format!("name_{}", test_id)); + let password = "password123".to_string(); + + let user = db.create_user(name, username, password).await?; + + if let Ok(mut users) = self.created_users.lock() { + users.push(user.id); + } + + Ok(user) + } + + pub async fn create_multiple_users( + &self, + db: &Database, + count: usize, + ) -> Result, backend::error::ApiError> { + let mut users = Vec::new(); + + for i in 0..count { + let username = format!("user_{}_{}", self.test_id, i); + let name = format!("name_{}_{}", self.test_id, i); + let user = self.create_user(db, Some(username), Some(name)).await?; + users.push(user); + } + + Ok(users) + } + + pub async fn get_user_by_id( + &self, + db: &Database, + id: Uuid, + ) -> Result, backend::error::ApiError> { + db.get_user(id).await + } + + pub async fn get_all_users( + &self, + db: &Database, + ) -> Result, backend::error::ApiError> { + db.get_users().await + } + + pub async fn assert_user_exists(&self, db: &Database, id: Uuid) -> bool { + dbg!("Check if user exists with ID: {}", id); + matches!(self.get_user_by_id(db, id).await, Ok(Some(_))) + } + + pub async fn assert_user_count(&self, db: &Database, expected: usize) -> bool { + match self.get_all_users(db).await { + Ok(users) => users.len() == expected, + Err(_) => false, + } + } + + pub async fn assert_user_not_exists(&self, db: &Database, id: Uuid) -> bool { + !self.assert_user_exists(db, id).await + } + + pub async fn delete_user( + &self, + db: &Database, + id: Uuid, + ) -> Result<(), backend::error::ApiError> { + db.delete_user(id).await?; + + if let Ok(mut users) = self.created_users.lock() { + users.retain(|&user_id| user_id != id); + } + + Ok(()) + } + + pub async fn cleanup_users(&self, db: &Database) { + if let Ok(users) = self.created_users.lock() { + for user_id in users.iter() { + let _ = db.delete_user(*user_id).await; + } + } + + if let Ok(mut users) = self.created_users.lock() { + users.clear(); + } + } +} diff --git a/crates/backend/tests/common/mod.rs b/crates/backend/tests/common/mod.rs index bcb7519..9b34500 100644 --- a/crates/backend/tests/common/mod.rs +++ b/crates/backend/tests/common/mod.rs @@ -1,76 +1,5 @@ -use backend::{Database, build_database_url}; -use log::{debug, info}; -use migration::{Migrator, MigratorTrait}; -use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; -use testcontainers_modules::{postgres::Postgres, redis::Redis}; - +pub mod db_helpers; +pub mod setup; pub mod test_helpers; -pub async fn setup() -> (ContainerAsync, ContainerAsync, Database) { - let postgres = Postgres::default() - .with_env_var("POSTGRES_DB", "test_db") - .with_env_var("POSTGRES_USER", "postgres") - .with_env_var("POSTGRES_PASSWORD", "postgres") - .start() - .await - .expect("Failed to start PostgreSQL container"); - - let redis = Redis::default() - .start() - .await - .expect("Failed to start Redis container"); - - let postgres_port = postgres.get_host_port_ipv4(5432).await.unwrap(); - let redis_port = redis.get_host_port_ipv4(6379).await.unwrap(); - - debug!("PostgreSQL container started on port: {}", postgres_port); - debug!("Redis container started on port: {}", redis_port); - - // Wait for PostgreSQL to be ready - wait_for_postgres_ready(&postgres).await; - - unsafe { - std::env::set_var("DB_HOST", "127.0.0.1"); - std::env::set_var("DB_PORT", postgres_port.to_string()); - std::env::set_var("DB_NAME", "test_db"); - std::env::set_var("DB_USER", "postgres"); - std::env::set_var("DB_PASSWORD", "postgres"); - std::env::set_var("REDIS_HOST", "127.0.0.1"); - std::env::set_var("REDIS_PORT", redis_port.to_string()); - } - - let database_url = build_database_url(); - info!("Database URL: {}", database_url); - - let database = Database::new(database_url.into()).await.unwrap(); - - Migrator::up(database.connection(), None).await.unwrap(); - - (postgres, redis, database) -} - -async fn wait_for_postgres_ready(container: &ContainerAsync) { - info!("Waiting for PostgreSQL to be ready..."); - - for attempt in 1..=30 { - match container.stdout_to_vec().await { - Ok(logs) => { - let log_string = String::from_utf8_lossy(&logs); - - if log_string.contains("database system is ready to accept connections") { - info!("PostgreSQL is ready after {} attempts", attempt); - return; - } - - debug!("Attempt {}: PostgreSQL not ready yet", attempt); - } - Err(e) => { - debug!("Attempt {}: Failed to read logs: {}", attempt, e); - } - } - - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - } - - panic!("PostgreSQL failed to become ready within 30 seconds"); -} +pub use setup::setup; diff --git a/crates/backend/tests/common/setup.rs b/crates/backend/tests/common/setup.rs new file mode 100644 index 0000000..071a30f --- /dev/null +++ b/crates/backend/tests/common/setup.rs @@ -0,0 +1,90 @@ +use std::{thread, time::Duration}; + +use backend::{Database, build_database_url}; +use log::{debug, info}; +use migration::{Migrator, MigratorTrait}; +use sea_orm::ConnectOptions; +use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; +use testcontainers_modules::{postgres::Postgres, redis::Redis}; + +pub async fn setup() -> (ContainerAsync, ContainerAsync, Database) { + let postgres = Postgres::default() + .with_tag("latest") + .with_env_var("POSTGRES_DB", "test_db") + .with_env_var("POSTGRES_USER", "postgres") + .with_env_var("POSTGRES_PASSWORD", "postgres") + .start() + .await + .expect("Failed to start PostgreSQL container"); + + let redis = Redis::default() + .with_tag("latest") + .start() + .await + .expect("Failed to start Redis container"); + + let postgres_port = postgres.get_host_port_ipv4(5432).await.unwrap(); + let redis_port = redis.get_host_port_ipv4(6379).await.unwrap(); + + debug!("PostgreSQL container started on port: {}", postgres_port); + debug!("Redis container started on port: {}", redis_port); + + // Wait for PostgreSQL to be ready + wait_for_postgres_ready(&postgres).await; + dbg!("PostgreSQL is ready - Starting to sleep"); + thread::sleep(Duration::from_secs(10)); + + unsafe { + std::env::set_var("DB_HOST", "127.0.0.1"); + std::env::set_var("DB_PORT", postgres_port.to_string()); + std::env::set_var("DB_NAME", "test_db"); + std::env::set_var("DB_USER", "postgres"); + std::env::set_var("DB_PASSWORD", "postgres"); + std::env::set_var("REDIS_HOST", "127.0.0.1"); + std::env::set_var("REDIS_PORT", redis_port.to_string()); + } + + let database_url = build_database_url(); + info!("Database URL: {}", database_url); + + // Configure connection pool for tests + let mut opts = ConnectOptions::new(database_url); + opts.max_connections(10) + .min_connections(2) + .connect_timeout(std::time::Duration::from_secs(30)) + .acquire_timeout(std::time::Duration::from_secs(30)) + .idle_timeout(std::time::Duration::from_secs(60)) + .max_lifetime(std::time::Duration::from_secs(600)); + + let database = Database::new(opts).await.unwrap(); + + Migrator::up(database.connection(), None).await.unwrap(); + + (postgres, redis, database) +} + +async fn wait_for_postgres_ready(container: &ContainerAsync) { + info!("Waiting for PostgreSQL to be ready..."); + + for attempt in 1..=30 { + match container.stdout_to_vec().await { + Ok(logs) => { + let log_string = String::from_utf8_lossy(&logs); + + if log_string.contains("database system is ready to accept connections") { + info!("PostgreSQL is ready after {} attempts", attempt); + return; + } + + debug!("Attempt {}: PostgreSQL not ready yet", attempt); + } + Err(e) => { + debug!("Attempt {}: Failed to read logs: {}", attempt, e); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + + panic!("PostgreSQL failed to become ready within 30 seconds"); +} diff --git a/crates/backend/tests/common/test_helpers.rs b/crates/backend/tests/common/test_helpers.rs index 4b44d43..2873e96 100644 --- a/crates/backend/tests/common/test_helpers.rs +++ b/crates/backend/tests/common/test_helpers.rs @@ -1,5 +1,6 @@ use backend::Database; use lazy_static::lazy_static; +use std::sync::atomic::{AtomicU64, Ordering}; use testcontainers::ContainerAsync; use testcontainers_modules::{postgres::Postgres, redis::Redis}; @@ -8,26 +9,65 @@ use super::setup; struct TestState { _postgres: ContainerAsync, _redis: ContainerAsync, - database: Database, } lazy_static! { static ref TEST_STATE: tokio::sync::OnceCell = tokio::sync::OnceCell::new(); } -pub async fn get_database() -> &'static Database { - let state = TEST_STATE +pub async fn get_database() -> Database { + let _state = TEST_STATE .get_or_init(|| async { - let (postgres, redis, database) = setup().await; + let (postgres, redis, _database) = setup().await; TestState { _postgres: postgres, _redis: redis, - database, } }) .await; - &state.database + // Create a new database connection for each test + let database_url = backend::build_database_url(); + let mut opts = sea_orm::ConnectOptions::new(database_url); + opts.max_connections(5) + .min_connections(1) + .connect_timeout(std::time::Duration::from_secs(10)) + .acquire_timeout(std::time::Duration::from_secs(10)); + + Database::new(opts).await.unwrap() +} + +static TEST_COUNTER: AtomicU64 = AtomicU64::new(1); + +pub fn get_unique_test_id() -> String { + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("test_{}_{}", counter, timestamp) +} + +#[derive(Clone)] +pub struct TestContext { + pub test_id: String, + pub created_users: std::sync::Arc>>, + pub created_projects: std::sync::Arc>>, +} + +impl TestContext { + pub fn new() -> Self { + Self { + test_id: get_unique_test_id(), + created_users: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + created_projects: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + + pub async fn cleanup_all(&self, db: &Database) { + self.cleanup_projects(db).await; + self.cleanup_users(db).await; + } } #[macro_export] @@ -37,7 +77,7 @@ macro_rules! create_test_app { actix_web::test::init_service( actix_web::App::new() - .app_data(actix_web::web::Data::new(db.clone())) + .app_data(actix_web::web::Data::new(db)) .service( actix_web::web::scope("/api/v1") .configure(backend::controller::register_controllers), diff --git a/crates/backend/tests/endpoints/user.rs b/crates/backend/tests/endpoints/user.rs index bed9260..7b73272 100644 --- a/crates/backend/tests/endpoints/user.rs +++ b/crates/backend/tests/endpoints/user.rs @@ -1,12 +1,10 @@ use actix_web::{http::header, test}; use serde::{Deserialize, Serialize}; -use crate::create_test_app; +use crate::{common::test_helpers::TestContext, create_test_app}; #[cfg(test)] mod tests { - use serde_json::json; - use super::*; #[derive(Deserialize, Serialize, Debug, Clone)] @@ -18,47 +16,155 @@ mod tests { #[actix_web::test] async fn test_create_user() { + let ctx: TestContext = TestContext::new(); + let db = &crate::common::test_helpers::get_database().await; + let app = create_test_app!(); + // Create JSON payload using TestContext's ID + let user_data = serde_json::json!({ + "username": format!("user_{}", ctx.test_id), + "name": format!("Test User {}", ctx.test_id), + "password": "password123" + }); + let resp = test::TestRequest::post() .uri("/api/v1/user") .insert_header(header::ContentType::json()) - .set_payload( - json!({ - "username": "testuser", - "name": "Test User", - "password": "password" - }) - .to_string(), - ) + .set_payload(user_data.to_string()) + .send_request(&app) + .await; + + dbg!(&resp); + let status = resp.status(); + + assert!( + status.is_success(), + "Expected success status, got: {}", + status + ); + + let user: RespCreateUser = test::read_body_json(resp).await; + + assert!(!user.name.is_empty()); + assert!(!user.username.is_empty()); + assert!(user.username.starts_with("user_test_")); + assert!(user.name.starts_with("Test User")); + assert!(status.is_success()); + + let user_id = uuid::Uuid::parse_str(&user.id).unwrap(); + assert!(ctx.assert_user_exists(db, user_id).await); + + ctx.cleanup_all(db).await; + } + + #[actix_web::test] + async fn test_delete_user() { + let ctx = TestContext::new(); + let db = &crate::common::test_helpers::get_database().await; + + let app = create_test_app!(); + + let user = ctx.create_user(db, None, None).await.unwrap(); + + // Check if user exists before deletion + assert!(ctx.assert_user_exists(db, user.id).await); + + // Delete the user via API + let delete_resp = test::TestRequest::delete() + .uri(&format!("/api/v1/user/{}", user.id)) + .send_request(&app) + .await; + let delete_status = delete_resp.status(); + + dbg!(&delete_resp); + + let delete_message: String = test::read_body_json(delete_resp).await; + assert_eq!(delete_message, format!("User {} deleted", user.id)); + assert!( + delete_status.is_success(), + "Failed to delete user with status: {:?}", + delete_status + ); + + // Verify user no longer exists in database + assert!(ctx.assert_user_not_exists(db, user.id).await); + + // Cleanup + ctx.cleanup_all(db).await; + } + + #[actix_web::test] + async fn test_get_users() { + let ctx = TestContext::new(); + let db = &crate::common::test_helpers::get_database().await; + + let app = create_test_app!(); + + // Create some test users + let users = ctx.create_multiple_users(db, 3).await.unwrap(); + assert_eq!(users.len(), 3); + + // Test the API endpoint + let resp = test::TestRequest::get() + .uri("/api/v1/user") .send_request(&app) .await; let status = resp.status(); - let user: RespCreateUser = test::read_body_json(resp).await; - - assert!(user.name == "Test User"); - assert!(user.username == "testuser"); + let api_users: Vec = test::read_body_json(resp).await; assert!(status.is_success()); + assert!(api_users.len() >= 3); // At least our 3 users (could be more from other tests) - let resp_del = test::TestRequest::delete() - .uri(&format!("/api/v1/user/{}", user.id)) + // Verify our users are in the response + for user in &users { + let found = api_users.iter().any(|api_user| { + api_user.id == user.id.to_string() + && api_user.username == user.username + && api_user.name == user.name + }); + assert!(found, "User {} not found in API response", user.username); + } + + // Verify our created users exist in database + for user in &users { + assert!(ctx.assert_user_exists(db, user.id).await); + } + + // Cleanup + ctx.cleanup_all(db).await; + } + + #[actix_web::test] + async fn test_delete_nonexistent_user() { + let ctx = TestContext::new(); + let db = &crate::common::test_helpers::get_database().await; + + let app = create_test_app!(); + let fake_id = "00000000-0000-0000-0000-000000000000"; + + // Verify the fake ID doesn't exist in database + let fake_uuid = uuid::Uuid::parse_str(fake_id).unwrap(); + assert!(ctx.assert_user_not_exists(db, fake_uuid).await); + + // Try to delete non-existent user + let resp = test::TestRequest::delete() + .uri(&format!("/api/v1/user/{}", fake_id)) .send_request(&app) .await; - let status_del = resp_del.status(); - - let delete_message: String = test::read_body_json(resp_del).await; - assert_eq!(delete_message, format!("User {} deleted", user.id)); + let status = resp.status(); assert!( - status_del.is_success(), - "Failed to delete user with status: {:?}", - status_del + status.is_client_error() || status.is_server_error(), + "Expected error for non-existent user, got: {}", + status ); - // Debugging output - dbg!(user); - dbg!(delete_message); + // Verify it still doesn't exist + assert!(ctx.assert_user_not_exists(db, fake_uuid).await); + + // Cleanup + ctx.cleanup_all(db).await; } }