Merge pull request 'User-Integration Tests and better infrastructure' (#28) from integration-test-addition into main
Reviewed-on: #28
This commit is contained in:
commit
3f2a3efa16
21 changed files with 678 additions and 188 deletions
|
@ -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") }),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<Database>,
|
||||
user: web::Json<CreateUser>,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<Vec<entity::user::Model>, ApiError> {
|
||||
|
|
|
@ -66,4 +66,3 @@ impl MessageResponse {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String> {
|
||||
dotenvy::var(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_env_var(name: &str) -> Result<String, std::env::VarError> {
|
||||
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::<u16>().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};
|
||||
|
|
|
@ -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<String> {
|
||||
dotenvy::var(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_env_var(name: &str) -> Result<String, std::env::VarError> {
|
||||
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::<u16>().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::*;
|
||||
|
|
29
crates/backend/src/utils.rs
Normal file
29
crates/backend/src/utils.rs
Normal file
|
@ -0,0 +1,29 @@
|
|||
use log::info;
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn get_env_var(name: &str) -> dotenvy::Result<String> {
|
||||
dotenvy::var(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_env_var(name: &str) -> Result<String, std::env::VarError> {
|
||||
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::<u16>().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
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{controller, db, entity, error};
|
||||
use crate::{controller, db, db::entity, error};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
|
|
123
crates/backend/tests/common/db_helpers/auth_helpers.rs
Normal file
123
crates/backend/tests/common/db_helpers/auth_helpers.rs
Normal file
|
@ -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<String>,
|
||||
name: Option<String>,
|
||||
password: String,
|
||||
) -> Result<entity::user::Model, backend::error::ApiError> {
|
||||
self.create_user_with_password(db, username, name, password)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn verify_user_login(
|
||||
&self,
|
||||
db: &Database,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<Uuid, backend::error::ApiError> {
|
||||
db.verify_local_user(username, password).await
|
||||
}
|
||||
|
||||
pub async fn create_authenticated_user(
|
||||
&self,
|
||||
db: &Database,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> 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<Vec<(entity::user::Model, String)>, 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<bool> {
|
||||
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<String>,
|
||||
password: Option<String>,
|
||||
) -> 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))
|
||||
}
|
||||
}
|
||||
*/
|
3
crates/backend/tests/common/db_helpers/mod.rs
Normal file
3
crates/backend/tests/common/db_helpers/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod user_helpers;
|
||||
pub mod project_helpers;
|
||||
pub mod auth_helpers;
|
125
crates/backend/tests/common/db_helpers/project_helpers.rs
Normal file
125
crates/backend/tests/common/db_helpers/project_helpers.rs
Normal file
|
@ -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<String>,
|
||||
) -> Result<entity::project::Model, backend::error::ApiError> {
|
||||
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<entity::project::Model, backend::error::ApiError> {
|
||||
self.create_project(db, Some(name)).await
|
||||
}
|
||||
|
||||
pub async fn create_multiple_projects(
|
||||
&self,
|
||||
db: &Database,
|
||||
count: usize,
|
||||
) -> Result<Vec<entity::project::Model>, 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<Option<entity::project::Model>, backend::error::ApiError> {
|
||||
db.get_project(id).await
|
||||
}
|
||||
|
||||
pub async fn get_all_projects(
|
||||
&self,
|
||||
db: &Database,
|
||||
) -> Result<Vec<entity::project::Model>, backend::error::ApiError> {
|
||||
db.get_projects().await
|
||||
}
|
||||
|
||||
pub async fn update_project(
|
||||
&self,
|
||||
db: &Database,
|
||||
id: &Uuid,
|
||||
name: String,
|
||||
) -> Result<entity::project::Model, backend::error::ApiError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
99
crates/backend/tests/common/db_helpers/user_helpers.rs
Normal file
99
crates/backend/tests/common/db_helpers/user_helpers.rs
Normal file
|
@ -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<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<entity::user::Model, backend::error::ApiError> {
|
||||
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<Vec<entity::user::Model>, 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<Option<entity::user::Model>, backend::error::ApiError> {
|
||||
db.get_user(id).await
|
||||
}
|
||||
|
||||
pub async fn get_all_users(
|
||||
&self,
|
||||
db: &Database,
|
||||
) -> Result<Vec<entity::user::Model>, 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Postgres>, ContainerAsync<Redis>, 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<Postgres>) {
|
||||
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;
|
||||
|
|
90
crates/backend/tests/common/setup.rs
Normal file
90
crates/backend/tests/common/setup.rs
Normal file
|
@ -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<Postgres>, ContainerAsync<Redis>, 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<Postgres>) {
|
||||
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");
|
||||
}
|
|
@ -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<Postgres>,
|
||||
_redis: ContainerAsync<Redis>,
|
||||
database: Database,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref TEST_STATE: tokio::sync::OnceCell<TestState> = 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<std::sync::Mutex<Vec<uuid::Uuid>>>,
|
||||
pub created_projects: std::sync::Arc<std::sync::Mutex<Vec<uuid::Uuid>>>,
|
||||
}
|
||||
|
||||
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),
|
||||
|
|
|
@ -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<RespCreateUser> = 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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue