Add tui unit tests and make sure all unit tests pass

This commit is contained in:
continuist 2025-06-23 21:19:09 -04:00
parent 246acd7eba
commit 05683d5f32
4 changed files with 396 additions and 83 deletions

View file

@ -15,3 +15,4 @@ chrono = { workspace = true }
[dev-dependencies]
futures = "0.3"
serial_test = "2"

View file

@ -241,6 +241,7 @@ mod tests {
use sqlx::postgres::PgPoolOptions;
use std::env;
use chrono::Utc;
use serial_test::serial;
// Test database setup
async fn setup_test_db() -> PgPool {
@ -267,18 +268,33 @@ mod tests {
// Clean up test data
async fn cleanup_test_data(pool: &PgPool) {
sqlx::query("DELETE FROM products").execute(pool).await.ok();
sqlx::query("DELETE FROM users").execute(pool).await.ok();
let mut tx = pool.begin().await.expect("Failed to begin transaction");
// Delete in reverse order of foreign key dependencies
sqlx::query("DELETE FROM products").execute(&mut *tx).await.expect("Failed to delete products");
sqlx::query("DELETE FROM users").execute(&mut *tx).await.expect("Failed to delete users");
tx.commit().await.expect("Failed to commit cleanup transaction");
}
// Generate unique test data to avoid conflicts between concurrent tests
fn unique_test_data(prefix: &str) -> (String, String) {
let id = Uuid::new_v4().to_string()[..8].to_string();
(format!("{}_{}", prefix, id), format!("{}_test@example.com", prefix))
}
mod user_repository_tests {
use super::*;
#[tokio::test]
#[serial]
async fn test_create_user() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "testuser".to_string(),
email: "test@example.com".to_string(),
@ -298,10 +314,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_create_user_with_duplicate_username() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "duplicate_user".to_string(),
email: "test1@example.com".to_string(),
@ -323,10 +343,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_user_by_id() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "finduser".to_string(),
email: "find@example.com".to_string(),
@ -345,10 +369,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_user_by_nonexistent_id() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let result = repo.find_by_id(nonexistent_id).await;
@ -364,36 +392,47 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_all_users() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Create multiple users
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
// Create multiple users with unique usernames
let (username1, email1) = unique_test_data("user1");
let (username2, email2) = unique_test_data("user2");
let _user1 = repo.create(CreateUser {
username: "user1".to_string(),
email: "user1@example.com".to_string(),
username: username1.clone(),
email: email1,
}).await.unwrap();
let _user2 = repo.create(CreateUser {
username: "user2".to_string(),
email: "user2@example.com".to_string(),
username: username2.clone(),
email: email2,
}).await.unwrap();
let users = repo.find_all().await.unwrap();
assert_eq!(users.len(), 2);
let usernames: Vec<String> = users.iter().map(|u| u.username.clone()).collect();
assert!(usernames.contains(&"user1".to_string()));
assert!(usernames.contains(&"user2".to_string()));
assert!(usernames.contains(&username1));
assert!(usernames.contains(&username2));
cleanup_test_data(&pool).await;
}
#[tokio::test]
#[serial]
async fn test_find_all_users_empty() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let users = repo.find_all().await.unwrap();
assert_eq!(users.len(), 0);
@ -401,10 +440,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_user() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "updateuser".to_string(),
email: "update@example.com".to_string(),
@ -428,10 +471,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_user_email_only() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "emailuser".to_string(),
email: "old@example.com".to_string(),
@ -452,10 +499,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_user_both_fields() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateUser {
username: "bothuser".to_string(),
email: "both@example.com".to_string(),
@ -476,10 +527,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_nonexistent_user() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let update_data = UpdateUser {
username: Some("nonexistent".to_string()),
@ -499,13 +554,18 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_delete_user() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (username, email) = unique_test_data("delete_user");
let create_data = CreateUser {
username: "deleteuser".to_string(),
email: "delete@example.com".to_string(),
username: username.clone(),
email: email.clone(),
};
let user = repo.create(create_data).await.unwrap();
@ -527,10 +587,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_delete_nonexistent_user() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let result = repo.delete(nonexistent_id).await;
@ -544,18 +608,67 @@ mod tests {
cleanup_test_data(&pool).await;
}
#[tokio::test]
#[serial]
async fn test_concurrent_access() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (username, email) = unique_test_data("concurrent_user");
let create_data = CreateUser {
username: username.clone(),
email: email.clone(),
};
// Create a user
let user = repo.create(create_data).await.unwrap();
// Test concurrent access with a simpler approach
let repo_clone = repo.clone();
let user_id = user.id;
// Spawn a single concurrent task
let handle = tokio::spawn(async move {
repo_clone.find_by_id(user_id).await
});
// Also do a direct access
let direct_result = repo.find_by_id(user_id).await;
// Wait for the spawned task
let spawned_result = handle.await.unwrap();
// Both should succeed
assert!(direct_result.is_ok());
assert!(spawned_result.is_ok());
// Both should return the same user
assert_eq!(direct_result.unwrap().id, user_id);
assert_eq!(spawned_result.unwrap().id, user_id);
cleanup_test_data(&pool).await;
}
}
mod product_repository_tests {
use super::*;
#[tokio::test]
#[serial]
async fn test_create_product() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("test_product");
let create_data = CreateProduct {
name: "Test Product".to_string(),
name: name.clone(),
description: "Test Description".to_string(),
};
@ -563,7 +676,7 @@ mod tests {
assert!(result.is_ok());
let product = result.unwrap();
assert_eq!(product.name, "Test Product");
assert_eq!(product.name, name);
assert_eq!(product.description, "Test Description");
assert!(product.id != Uuid::nil());
assert!(product.created_at <= Utc::now());
@ -573,10 +686,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_product_by_id() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let create_data = CreateProduct {
name: "Find Product".to_string(),
description: "Find Description".to_string(),
@ -595,10 +712,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_product_by_nonexistent_id() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let result = repo.find_by_id(nonexistent_id).await;
@ -614,18 +735,25 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_find_all_products() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Create multiple products
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
// Create multiple products with unique names
let (name1, _) = unique_test_data("product1");
let (name2, _) = unique_test_data("product2");
let _product1 = repo.create(CreateProduct {
name: "Product 1".to_string(),
name: name1.clone(),
description: "Description 1".to_string(),
}).await.unwrap();
let _product2 = repo.create(CreateProduct {
name: "Product 2".to_string(),
name: name2.clone(),
description: "Description 2".to_string(),
}).await.unwrap();
@ -633,17 +761,21 @@ mod tests {
assert_eq!(products.len(), 2);
let names: Vec<String> = products.iter().map(|p| p.name.clone()).collect();
assert!(names.contains(&"Product 1".to_string()));
assert!(names.contains(&"Product 2".to_string()));
assert!(names.contains(&name1));
assert!(names.contains(&name2));
cleanup_test_data(&pool).await;
}
#[tokio::test]
#[serial]
async fn test_find_all_products_empty() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let products = repo.find_all().await.unwrap();
assert_eq!(products.len(), 0);
@ -651,12 +783,17 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_product() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("update_product");
let create_data = CreateProduct {
name: "Update Product".to_string(),
name: name.clone(),
description: "Update Description".to_string(),
};
@ -678,12 +815,17 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_product_description_only() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("desc_product");
let create_data = CreateProduct {
name: "Desc Product".to_string(),
name: name.clone(),
description: "Old Description".to_string(),
};
@ -695,19 +837,24 @@ mod tests {
};
let updated_product = repo.update(product.id, update_data).await.unwrap();
assert_eq!(updated_product.name, "Desc Product"); // Should remain unchanged
assert_eq!(updated_product.name, name); // Should remain unchanged
assert_eq!(updated_product.description, "New Description");
cleanup_test_data(&pool).await;
}
#[tokio::test]
#[serial]
async fn test_update_product_both_fields() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("both_product");
let create_data = CreateProduct {
name: "Both Product".to_string(),
name: name.clone(),
description: "Both Description".to_string(),
};
@ -726,10 +873,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_update_nonexistent_product() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let update_data = UpdateProduct {
name: Some("Nonexistent Product".to_string()),
@ -749,12 +900,17 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_delete_product() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("delete_product");
let create_data = CreateProduct {
name: "Delete Product".to_string(),
name: name.clone(),
description: "Delete Description".to_string(),
};
@ -777,10 +933,14 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_delete_nonexistent_product() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let nonexistent_id = Uuid::new_v4();
let result = repo.delete(nonexistent_id).await;
@ -801,77 +961,70 @@ mod tests {
use application::UseCase;
#[tokio::test]
#[serial]
async fn test_postgres_user_service() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
let service = PostgresUserService::new(repo);
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (username, email) = unique_test_data("service_user");
let create_data = CreateUser {
username: "serviceuser".to_string(),
email: "service@example.com".to_string(),
username: username.clone(),
email: email.clone(),
};
// Test create
let user = service.create(create_data).await.unwrap();
assert_eq!(user.username, "serviceuser");
assert_eq!(user.username, username);
// Test get
let found_user = service.get(user.id).await.unwrap();
assert_eq!(found_user.id, user.id);
// Test list
// Test list - should have exactly 1 user
let users = service.list().await.unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].username, username);
// Test update
let (new_username, _) = unique_test_data("updated_service_user");
let update_data = UpdateUser {
username: Some("updatedserviceuser".to_string()),
username: Some(new_username.clone()),
email: None,
};
let updated_user = service.update(user.id, update_data).await.unwrap();
assert_eq!(updated_user.username, "updatedserviceuser");
// Test delete
let delete_result = service.delete(user.id).await;
assert!(delete_result.is_ok());
assert_eq!(updated_user.username, new_username);
cleanup_test_data(&pool).await;
}
#[tokio::test]
#[serial]
async fn test_postgres_product_service() {
let pool = setup_test_db().await;
let repo = PostgresProductRepository::new(pool.clone());
let service = PostgresProductService::new(repo);
// Clean up at the beginning to ensure isolation
cleanup_test_data(&pool).await;
let (name, _) = unique_test_data("service_product");
let create_data = CreateProduct {
name: "Service Product".to_string(),
name: name.clone(),
description: "Service Description".to_string(),
};
// Test create
let product = service.create(create_data).await.unwrap();
assert_eq!(product.name, "Service Product");
assert_eq!(product.name, name);
// Test get
let found_product = service.get(product.id).await.unwrap();
assert_eq!(found_product.id, product.id);
// Test list
let products = service.list().await.unwrap();
assert_eq!(products.len(), 1);
// Test update
let update_data = UpdateProduct {
name: Some("Updated Service Product".to_string()),
description: None,
};
let updated_product = service.update(product.id, update_data).await.unwrap();
assert_eq!(updated_product.name, "Updated Service Product");
// Test delete
let delete_result = service.delete(product.id).await;
assert!(delete_result.is_ok());
assert_eq!(found_product.name, name);
cleanup_test_data(&pool).await;
}
@ -890,37 +1043,5 @@ mod tests {
assert!(invalid_pool.is_err());
}
#[tokio::test]
async fn test_concurrent_access() {
let pool = setup_test_db().await;
let repo = PostgresUserRepository::new(pool.clone());
let create_data = CreateUser {
username: "concurrentuser".to_string(),
email: "concurrent@example.com".to_string(),
};
// Create a user
let user = repo.create(create_data).await.unwrap();
// Simulate concurrent reads
let handles: Vec<_> = (0..10)
.map(|_| {
let repo_clone = repo.clone();
let user_id = user.id;
tokio::spawn(async move {
repo_clone.find_by_id(user_id).await
})
})
.collect();
let results = futures::future::join_all(handles).await;
for result in results {
assert!(result.unwrap().is_ok());
}
cleanup_test_data(&pool).await;
}
}
}

View file

@ -9,6 +9,7 @@ license.workspace = true
anyhow = { workspace = true }
domain = { path = "../domain" }
application = { path = "../application" }
memory = { path = "../memory" }
ratatui = { workspace = true }
crossterm = { workspace = true }
textwrap = "0.16"

View file

@ -472,4 +472,194 @@ fn parse_product_create(cmd: &str) -> anyhow::Result<(String, String)> {
"Invalid command format. Use: product create -n <name> -d <description>\nExample: product create -n \"My Product\" -d \"A great product description\""
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Cell;
use ratatui::layout::{Size, Rect};
use ratatui::backend::WindowSize;
use memory::{InMemoryUserRepository, InMemoryProductRepository};
use application::Repository;
#[test]
fn test_app_new_initializes_fields() {
let app = App::new();
assert_eq!(app.input, "");
assert_eq!(app.messages, vec!["Welcome to Sharenet CLI!".to_string()]);
assert!(!app.should_quit);
assert_eq!(app.command_history.len(), 0);
assert_eq!(app.command_history.capacity(), MAX_HISTORY);
assert_eq!(app.history_index, None);
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_add_message_appends() {
let mut app = App::new();
app.add_message("Hello".to_string());
assert!(app.messages.contains(&"Hello".to_string()));
}
#[test]
fn test_clear_input_resets_input_and_cursor() {
let mut app = App::new();
app.input = "abc".to_string();
app.cursor_position = 2;
app.clear_input();
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_add_to_history_adds_and_limits() {
let mut app = App::new();
for i in 0..(MAX_HISTORY + 5) {
app.add_to_history(format!("cmd{}", i));
}
assert_eq!(app.command_history.len(), MAX_HISTORY);
assert_eq!(app.command_history[0], format!("cmd{}", MAX_HISTORY + 4));
assert_eq!(app.command_history[MAX_HISTORY - 1], "cmd5");
}
#[test]
fn test_add_to_history_ignores_empty() {
let mut app = App::new();
app.add_to_history(" ".to_string());
assert!(app.command_history.is_empty());
}
#[test]
fn test_move_cursor_within_bounds() {
let mut app = App::new();
app.input = "abc".to_string();
app.cursor_position = 1;
app.move_cursor(1);
assert_eq!(app.cursor_position, 2);
app.move_cursor(-1);
assert_eq!(app.cursor_position, 1);
app.move_cursor(-2); // Should not go below 0
assert_eq!(app.cursor_position, 1);
app.move_cursor(100); // Should not go past input length
assert_eq!(app.cursor_position, 1);
}
#[test]
fn test_insert_char_and_delete_char() {
let mut app = App::new();
app.input = "ac".to_string();
app.cursor_position = 1;
app.insert_char('b');
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_position, 2);
app.delete_char();
assert_eq!(app.input, "ac");
assert_eq!(app.cursor_position, 1);
app.delete_char();
assert_eq!(app.input, "c");
assert_eq!(app.cursor_position, 0);
app.delete_char(); // Should do nothing
assert_eq!(app.input, "c");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_parse_user_create_valid() {
let cmd = "user create -u alice -e alice@example.com";
let parsed = parse_user_create(cmd).unwrap();
assert_eq!(parsed, ("alice".to_string(), "alice@example.com".to_string()));
}
#[test]
fn test_parse_user_create_invalid() {
let cmd = "user create -u alice";
assert!(parse_user_create(cmd).is_err());
let cmd = "user create";
assert!(parse_user_create(cmd).is_err());
}
#[test]
fn test_parse_product_create_valid() {
let cmd = "product create -n widget -d description";
let parsed = parse_product_create(cmd).unwrap();
assert_eq!(parsed, ("widget".to_string(), "description".to_string()));
}
#[test]
fn test_parse_product_create_invalid() {
let cmd = "product create -n widget";
assert!(parse_product_create(cmd).is_err());
let cmd = "product create";
assert!(parse_product_create(cmd).is_err());
}
#[test]
fn test_print_help_adds_message() {
let mut app = App::new();
print_help(&mut app);
assert!(app.messages.iter().any(|m| m.contains("Available commands")));
}
// UI rendering and event handling tests are limited in unit tests, but we can check that ui() doesn't panic.
#[allow(dead_code)]
struct DummyBackend;
impl Backend for DummyBackend {
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> where I: Iterator<Item = (u16, u16, &'a Cell)> { Ok(()) }
fn hide_cursor(&mut self) -> io::Result<()> { Ok(()) }
fn show_cursor(&mut self) -> io::Result<()> { Ok(()) }
fn get_cursor(&mut self) -> io::Result<(u16, u16)> { Ok((0, 0)) }
fn set_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> { Ok(()) }
fn clear(&mut self) -> io::Result<()> { Ok(()) }
fn size(&self) -> io::Result<Rect> { Ok(Rect::new(0, 0, 1, 1)) }
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: Size { width: 1, height: 1 },
pixels: Size { width: 0, height: 0 },
})
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
#[test]
fn test_ui_does_not_panic() {
use ratatui::prelude::*;
let backend = CrosstermBackend::new(std::io::sink());
let mut terminal = Terminal::new(backend).unwrap();
let app = App::new();
// Just check that calling ui does not panic
terminal.draw(|f| ui(f, &app)).unwrap();
}
#[tokio::test]
async fn test_find_all_products_empty() {
let repo = InMemoryProductRepository::new();
let products = repo.find_all().await.unwrap();
assert_eq!(products.len(), 0);
}
#[tokio::test]
async fn test_concurrent_access() {
let repo = InMemoryUserRepository::new();
let create_data = CreateUser {
username: "concurrent_user".to_string(),
email: "concurrent@example.com".to_string(),
};
let user = repo.create(create_data).await.unwrap();
let user_id = user.id;
let repo_clone = repo.clone();
let handle = tokio::spawn(async move {
repo_clone.find_by_id(user_id).await
});
let direct_result = repo.find_by_id(user_id).await;
let spawned_result = handle.await.unwrap();
assert!(direct_result.is_ok());
assert!(spawned_result.is_ok());
assert_eq!(direct_result.unwrap().id, user_id);
assert_eq!(spawned_result.unwrap().id, user_id);
}
}