From 243f9f9aefdb8574438145f5268a60bac3187f4f Mon Sep 17 00:00:00 2001 From: continuist Date: Mon, 23 Jun 2025 21:55:02 -0400 Subject: [PATCH] Add cross-crate integration tests to a new integration-tests crate --- backend/crates/integration-tests/Cargo.toml | 32 + .../src/api_postgres_tests.rs | 654 ++++++++++++++++++ backend/crates/integration-tests/src/lib.rs | 13 + 3 files changed, 699 insertions(+) create mode 100644 backend/crates/integration-tests/Cargo.toml create mode 100644 backend/crates/integration-tests/src/api_postgres_tests.rs create mode 100644 backend/crates/integration-tests/src/lib.rs diff --git a/backend/crates/integration-tests/Cargo.toml b/backend/crates/integration-tests/Cargo.toml new file mode 100644 index 0000000..943f7e8 --- /dev/null +++ b/backend/crates/integration-tests/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "integration-tests" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +# Workspace dependencies +tokio.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +sqlx.workspace = true +axum.workspace = true +tower.workspace = true +serial_test = "2.0" + +# Local crates +api = { path = "../api" } +application = { path = "../application" } +domain = { path = "../domain" } +postgres = { path = "../postgres" } + +# Additional test dependencies +hyper = { version = "1.0", features = ["full"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } + +[dev-dependencies] +serial_test = "2.0" \ No newline at end of file diff --git a/backend/crates/integration-tests/src/api_postgres_tests.rs b/backend/crates/integration-tests/src/api_postgres_tests.rs new file mode 100644 index 0000000..42e7a49 --- /dev/null +++ b/backend/crates/integration-tests/src/api_postgres_tests.rs @@ -0,0 +1,654 @@ +/* + * This file is part of Sharenet. + * + * Sharenet is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. + * + * You may obtain a copy of the license at: + * https://creativecommons.org/licenses/by-nc-sa/4.0/ + * + * Copyright (c) 2024 Continuist + */ + +use axum::{ + body::Body, + http::{Request, StatusCode}, + Json, Router, +}; +use domain::{User, Product, CreateUser, UpdateUser, CreateProduct, UpdateProduct}; +use postgres::{PostgresUserRepository, PostgresProductRepository}; +use application::Service; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use std::env; +use std::sync::Arc; +use tower::ServiceExt; +use tower_http::trace::TraceLayer; +use tower_http::cors::{CorsLayer, Any}; +use uuid::Uuid; +use serde_json::json; +use axum::response::IntoResponse; +use serial_test::serial; +use application::UseCase; + +/// Application state containing user and product services. +pub struct AppState { + user_service: Arc, + product_service: Arc

, +} + +impl Clone for AppState +where + U: Clone, + P: Clone, +{ + fn clone(&self) -> Self { + Self { + user_service: self.user_service.clone(), + product_service: self.product_service.clone(), + } + } +} + +// Helper functions +async fn setup_test_db() -> PgPool { + let database_url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/sharenet_test".to_string()); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to test database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + cleanup_test_data(&pool).await; + pool +} + +async fn cleanup_test_data(pool: &PgPool) { + let mut tx = pool.begin().await.expect("Failed to begin transaction"); + 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"); +} + +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)) +} + +async fn create_test_app() -> Router { + let pool = setup_test_db().await; + let user_repo = PostgresUserRepository::new(pool.clone()); + let product_repo = PostgresProductRepository::new(pool.clone()); + let user_service = Service::new(user_repo); + let product_service = Service::new(product_repo); + let state = AppState { + user_service: Arc::new(user_service), + product_service: Arc::new(product_service), + }; + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + Router::new() + .route("/users", axum::routing::post(create_user)) + .route("/users/:id", axum::routing::get(get_user)) + .route("/users", axum::routing::get(list_users)) + .route("/users/:id", axum::routing::put(update_user)) + .route("/users/:id", axum::routing::delete(delete_user)) + .route("/products", axum::routing::post(create_product)) + .route("/products/:id", axum::routing::get(get_product)) + .route("/products", axum::routing::get(list_products)) + .route("/products/:id", axum::routing::put(update_product)) + .route("/products/:id", axum::routing::delete(delete_product)) + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +async fn extract_json(response: axum::response::Response) -> T { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +// Route handler functions +async fn create_user( + axum::extract::State(state): axum::extract::State, Service>>, + Json(data): Json, +) -> impl axum::response::IntoResponse { + match state.user_service.create(data).await { + Ok(user) => (StatusCode::CREATED, Json(user)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_user( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, +) -> impl axum::response::IntoResponse { + match state.user_service.get(id).await { + Ok(user) => (StatusCode::OK, Json(user)).into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn list_users( + axum::extract::State(state): axum::extract::State, Service>>, +) -> impl axum::response::IntoResponse { + match state.user_service.list().await { + Ok(users) => (StatusCode::OK, Json(users)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_user( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, + Json(data): Json, +) -> impl axum::response::IntoResponse { + match state.user_service.update(id, data).await { + Ok(user) => (StatusCode::OK, Json(user)).into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn delete_user( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, +) -> impl axum::response::IntoResponse { + match state.user_service.delete(id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn create_product( + axum::extract::State(state): axum::extract::State, Service>>, + Json(data): Json, +) -> impl axum::response::IntoResponse { + match state.product_service.create(data).await { + Ok(product) => (StatusCode::CREATED, Json(product)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_product( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, +) -> impl axum::response::IntoResponse { + match state.product_service.get(id).await { + Ok(product) => (StatusCode::OK, Json(product)).into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn list_products( + axum::extract::State(state): axum::extract::State, Service>>, +) -> impl axum::response::IntoResponse { + match state.product_service.list().await { + Ok(products) => (StatusCode::OK, Json(products)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_product( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, + Json(data): Json, +) -> impl axum::response::IntoResponse { + match state.product_service.update(id, data).await { + Ok(product) => (StatusCode::OK, Json(product)).into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn delete_product( + axum::extract::State(state): axum::extract::State, Service>>, + axum::extract::Path(id): axum::extract::Path, +) -> impl axum::response::IntoResponse { + match state.product_service.delete(id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +#[tokio::test] +#[serial] +async fn test_api_with_postgres_user_lifecycle() { + let app = create_test_app().await; + let (username, email) = unique_test_data("api_user"); + + // Test user creation via API + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": username, + "email": email + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_response.status(), StatusCode::CREATED); + + let user: User = extract_json(create_response).await; + assert_eq!(user.username, username); + assert_eq!(user.email, email); + + // Test user retrieval via API + let get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::OK); + + let retrieved_user: User = extract_json(get_response).await; + assert_eq!(retrieved_user.id, user.id); + assert_eq!(retrieved_user.username, username); + + // Test user update via API + let new_username = format!("{}_updated", username); + let update_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/users/{}", user.id)) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": new_username, + "email": email + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(update_response.status(), StatusCode::OK); + + let updated_user: User = extract_json(update_response).await; + assert_eq!(updated_user.username, new_username); + + // Test user deletion via API + let delete_response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/users/{}", user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + + // Verify user no longer exists + let get_deleted_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_deleted_response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn test_api_with_postgres_product_lifecycle() { + let app = create_test_app().await; + let (name, _) = unique_test_data("api_product"); + let description = "Test product description"; + + // Test product creation via API + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "name": name, + "description": description + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_response.status(), StatusCode::CREATED); + + let product: Product = extract_json(create_response).await; + assert_eq!(product.name, name); + assert_eq!(product.description, description); + + // Test product retrieval via API + let get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::OK); + + let retrieved_product: Product = extract_json(get_response).await; + assert_eq!(retrieved_product.id, product.id); + assert_eq!(retrieved_product.name, name); + + // Test product update via API + let new_name = format!("{}_updated", name); + let update_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/products/{}", product.id)) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "name": new_name, + "description": description + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(update_response.status(), StatusCode::OK); + + let updated_product: Product = extract_json(update_response).await; + assert_eq!(updated_product.name, new_name); + + // Test product deletion via API + let delete_response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/products/{}", product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + + // Verify product no longer exists + let get_deleted_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_deleted_response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn test_api_with_postgres_list_operations() { + let app = create_test_app().await; + + // Create multiple users + let (username1, email1) = unique_test_data("list_user1"); + let (username2, email2) = unique_test_data("list_user2"); + + let user1_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": username1, + "email": email1 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let user2_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": username2, + "email": email2 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(user1_response.status(), StatusCode::CREATED); + assert_eq!(user2_response.status(), StatusCode::CREATED); + + // Test list users via API + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/users") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(list_response.status(), StatusCode::OK); + + let users: Vec = extract_json(list_response).await; + assert_eq!(users.len(), 2); + + let usernames: Vec = users.iter().map(|u| u.username.clone()).collect(); + assert!(usernames.contains(&username1)); + assert!(usernames.contains(&username2)); + + // Create multiple products + let (name1, _) = unique_test_data("list_product1"); + let (name2, _) = unique_test_data("list_product2"); + + let product1_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "name": name1, + "description": "Description 1" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let product2_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "name": name2, + "description": "Description 2" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(product1_response.status(), StatusCode::CREATED); + assert_eq!(product2_response.status(), StatusCode::CREATED); + + // Test list products via API + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/products") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(list_response.status(), StatusCode::OK); + + let products: Vec = extract_json(list_response).await; + assert_eq!(products.len(), 2); + + let names: Vec = products.iter().map(|p| p.name.clone()).collect(); + assert!(names.contains(&name1)); + assert!(names.contains(&name2)); +} + +#[tokio::test] +#[serial] +async fn test_api_with_postgres_error_handling() { + let app = create_test_app().await; + + // Test getting non-existent user + let get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", Uuid::new_v4())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::NOT_FOUND); + + // Test getting non-existent product + let get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", Uuid::new_v4())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::NOT_FOUND); + + // Test creating user with duplicate username + let (username, email1) = unique_test_data("duplicate_user"); + + let create1_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": username, + "email": email1 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create1_response.status(), StatusCode::CREATED); + + let email2 = format!("{}_2@example.com", username); + let create2_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "username": username, + "email": email2 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create2_response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} \ No newline at end of file diff --git a/backend/crates/integration-tests/src/lib.rs b/backend/crates/integration-tests/src/lib.rs new file mode 100644 index 0000000..615c193 --- /dev/null +++ b/backend/crates/integration-tests/src/lib.rs @@ -0,0 +1,13 @@ +/* + * This file is part of Sharenet. + * + * Sharenet is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. + * + * You may obtain a copy of the license at: + * https://creativecommons.org/licenses/by-nc-sa/4.0/ + * + * Copyright (c) 2024 Continuist + */ + +#[cfg(test)] +pub mod api_postgres_tests; \ No newline at end of file