User-Integration Tests and better infrastructure #28

Merged
mixel merged 8 commits from integration-test-addition into main 2025-06-20 17:03:40 +02:00
21 changed files with 678 additions and 188 deletions

View file

@ -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") }),
);
}

View file

@ -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)

View file

@ -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)

View file

@ -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) {

View file

@ -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)

View file

@ -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>,

View file

@ -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;

View file

@ -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> {

View file

@ -66,4 +66,3 @@ impl MessageResponse {
}
}
}

View file

@ -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};

View file

@ -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::*;

View 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
}

View file

@ -1,6 +1,6 @@
use utoipa::OpenApi;
use crate::{controller, db, entity, error};
use crate::{controller, db, db::entity, error};
#[derive(OpenApi)]
#[openapi(

View 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))
}
}
*/

View file

@ -0,0 +1,3 @@
pub mod user_helpers;
pub mod project_helpers;
pub mod auth_helpers;

View 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();
}
}
}

View 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();
}
}
}

View file

@ -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;

View 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");
}

View file

@ -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),

View file

@ -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;
}
}