diff --git a/backend/crates/api/Cargo.toml b/backend/crates/api/Cargo.toml index 5d37a35..93a78e0 100644 --- a/backend/crates/api/Cargo.toml +++ b/backend/crates/api/Cargo.toml @@ -10,10 +10,14 @@ domain = { path = "../domain" } application = { path = "../application" } axum = { workspace = true } tokio = { workspace = true } -tower = "0.4" +tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["trace", "cors"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } serde = { workspace = true } serde_json = "1.0" uuid = { workspace = true } + +[dev-dependencies] +chrono = { workspace = true } +hyper = "1.0" diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index 2f2185e..e3821e5 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -1,3 +1,74 @@ +//! # api +//! +//! This crate provides a RESTful HTTP API for the Sharenet application using Axum. +//! It implements CRUD operations for users and products with proper error handling, +//! CORS support, and comprehensive testing. +//! +//! ## Features +//! - RESTful HTTP API with Axum framework +//! - CRUD operations for `User` and `Product` entities +//! - Generic service layer supporting different backends (memory, PostgreSQL) +//! - CORS support for cross-origin requests +//! - Request/response logging with tracing +//! - Comprehensive unit tests with mock services +//! - Proper HTTP status codes and error handling +//! +//! ## API Endpoints +//! +//! ### Users +//! - `POST /users` - Create a new user +//! - `GET /users/:id` - Get a user by ID +//! - `GET /users` - List all users +//! - `PUT /users/:id` - Update a user +//! - `DELETE /users/:id` - Delete a user +//! +//! ### Products +//! - `POST /products` - Create a new product +//! - `GET /products/:id` - Get a product by ID +//! - `GET /products` - List all products +//! - `PUT /products/:id` - Update a product +//! - `DELETE /products/:id` - Delete a product +//! +//! ## Usage +//! +//! ```rust +//! use api::run; +//! use std::net::SocketAddr; +//! +//! // Example with any service implementing UseCase and UseCase +//! #[tokio::main] +//! async fn main() { +//! let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); +//! +//! // Create your user and product services here +//! // let user_service = YourUserService::new(); +//! // let product_service = YourProductService::new(); +//! +//! // run(addr, user_service, product_service).await; +//! } +//! ``` +//! +//! ## Testing +//! +//! The crate includes comprehensive unit tests using mock services: +//! - Tests for all CRUD operations +//! - Error handling scenarios (not found, validation errors) +//! - Integration tests covering full entity lifecycles +//! - Uses `tower::ServiceExt::oneshot()` for testing Axum applications +//! +//! Run tests with: +//! ```bash +//! cargo test +//! ``` +//! +//! ## Dependencies +//! - `axum` - HTTP web framework +//! - `tower` - Middleware framework with `util` feature for testing +//! - `tower-http` - HTTP-specific middleware (CORS, tracing) +//! - `tracing` - Application logging and observability +//! - `serde` - Serialization/deserialization +//! - `uuid` - Unique identifier generation + /* * This file is part of Sharenet. * @@ -26,6 +97,11 @@ use tower_http::cors::{CorsLayer, Any}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, filter::EnvFilter}; use uuid::Uuid; +/// Application state containing user and product services. +/// +/// This struct holds references to the service implementations that handle +/// business logic for users and products. It's generic over the service types +/// to allow different implementations (memory, PostgreSQL, etc.). pub struct AppState { user_service: Arc, product_service: Arc

, @@ -44,6 +120,18 @@ where } } +/// Starts the HTTP server with the provided services. +/// +/// This function sets up the Axum application with all routes, middleware, +/// and state management. It configures CORS, request logging, and starts +/// listening on the specified address. +/// +/// # Arguments +/// * `addr` - The socket address to bind the server to +/// * `user_service` - Service implementation for user operations +/// * `product_service` - Service implementation for product operations +/// +/// See the module-level documentation for usage examples. pub async fn run(addr: SocketAddr, user_service: U, product_service: P) where U: UseCase + Clone + Send + Sync + 'static, @@ -87,6 +175,14 @@ where axum::serve(listener, app).await.unwrap(); } +/// Creates a new user. +/// +/// Accepts a JSON payload with `username` and `email` fields. +/// Returns the created user with generated ID and timestamps. +/// +/// # Response +/// - `201 Created` - User successfully created +/// - `500 Internal Server Error` - Service error async fn create_user( State(state): State>>, Json(data): Json, @@ -100,6 +196,11 @@ where } } +/// Retrieves a user by ID. +/// +/// # Response +/// - `200 OK` - User found and returned +/// - `404 Not Found` - User with specified ID not found async fn get_user( State(state): State>>, Path(id): Path, @@ -113,6 +214,11 @@ where } } +/// Lists all users. +/// +/// # Response +/// - `200 OK` - List of all users +/// - `500 Internal Server Error` - Service error async fn list_users( State(state): State>>, ) -> impl IntoResponse @@ -125,6 +231,14 @@ where } } +/// Updates a user by ID. +/// +/// Accepts a JSON payload with optional `username` and `email` fields. +/// Only provided fields will be updated. +/// +/// # Response +/// - `200 OK` - User successfully updated +/// - `404 Not Found` - User with specified ID not found async fn update_user( State(state): State>>, Path(id): Path, @@ -139,6 +253,11 @@ where } } +/// Deletes a user by ID. +/// +/// # Response +/// - `204 No Content` - User successfully deleted +/// - `404 Not Found` - User with specified ID not found async fn delete_user( State(state): State>>, Path(id): Path, @@ -152,6 +271,14 @@ where } } +/// Creates a new product. +/// +/// Accepts a JSON payload with `name` and `description` fields. +/// Returns the created product with generated ID and timestamps. +/// +/// # Response +/// - `201 Created` - Product successfully created +/// - `500 Internal Server Error` - Service error async fn create_product

( State(state): State, P>>, Json(data): Json, @@ -165,6 +292,11 @@ where } } +/// Retrieves a product by ID. +/// +/// # Response +/// - `200 OK` - Product found and returned +/// - `404 Not Found` - Product with specified ID not found async fn get_product

( State(state): State, P>>, Path(id): Path, @@ -178,6 +310,11 @@ where } } +/// Lists all products. +/// +/// # Response +/// - `200 OK` - List of all products +/// - `500 Internal Server Error` - Service error async fn list_products

( State(state): State, P>>, ) -> impl IntoResponse @@ -190,6 +327,14 @@ where } } +/// Updates a product by ID. +/// +/// Accepts a JSON payload with optional `name` and `description` fields. +/// Only provided fields will be updated. +/// +/// # Response +/// - `200 OK` - Product successfully updated +/// - `404 Not Found` - Product with specified ID not found async fn update_product

( State(state): State, P>>, Path(id): Path, @@ -204,6 +349,11 @@ where } } +/// Deletes a product by ID. +/// +/// # Response +/// - `204 No Content` - Product successfully deleted +/// - `404 Not Found` - Product with specified ID not found async fn delete_product

( State(state): State, P>>, Path(id): Path, @@ -216,3 +366,1166 @@ where Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), } } + +#[cfg(test)] +mod tests { + //! # API Tests + //! + //! This module contains comprehensive unit tests for the API endpoints. + //! Tests use mock services to verify HTTP request/response behavior + //! without requiring external dependencies. + //! + //! ## Test Structure + //! - `user_endpoints` - Tests for user CRUD operations + //! - `product_endpoints` - Tests for product CRUD operations + //! - `integration_tests` - End-to-end lifecycle tests + //! + //! ## Testing Approach + //! - Uses `tower::ServiceExt::oneshot()` for testing Axum applications + //! - Mock services implement the `UseCase` trait + //! - Tests verify HTTP status codes, response bodies, and error handling + //! - Integration tests cover complete entity lifecycles + + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + response::Response, + }; + use domain::{CreateProduct, CreateUser, UpdateProduct, UpdateUser, User, Product, DomainError}; + use application::{UseCase, ApplicationError}; + use std::sync::Arc; + use tokio::sync::RwLock; + use std::collections::HashMap; + use chrono::Utc; + use serde_json::json; + use tower::ServiceExt; + + /// Mock user service for testing. + /// + /// Implements the `UseCase` trait using an in-memory HashMap + /// for storing test data. Provides thread-safe access via `RwLock`. + #[derive(Clone)] + struct MockUserService { + users: Arc>>, + } + + impl MockUserService { + /// Creates a new mock user service with empty storage. + fn new() -> Self { + Self { + users: Arc::new(RwLock::new(HashMap::new())), + } + } + } + + impl UseCase for MockUserService { + fn create(&self, data: CreateUser) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + let id = Uuid::new_v4(); + let user = User { + id, + username: data.username, + email: data.email, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + guard.insert(id, user.clone()); + Ok(user) + } + } + + fn get(&self, id: Uuid) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let guard = users.read().await; + guard.get(&id) + .cloned() + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id)))) + } + } + + fn list(&self) -> impl std::future::Future, ApplicationError>> + Send { + let users = self.users.clone(); + async move { + let guard = users.read().await; + Ok(guard.values().cloned().collect()) + } + } + + fn update(&self, id: Uuid, data: UpdateUser) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + let user = guard.get_mut(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id))))?; + + if let Some(username) = data.username { + user.username = username; + } + if let Some(email) = data.email { + user.email = email; + } + user.updated_at = Utc::now(); + Ok(user.clone()) + } + } + + fn delete(&self, id: Uuid) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + guard.remove(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id))))?; + Ok(()) + } + } + } + + /// Mock product service for testing. + /// + /// Implements the `UseCase` trait using an in-memory HashMap + /// for storing test data. Provides thread-safe access via `RwLock`. + #[derive(Clone)] + struct MockProductService { + products: Arc>>, + } + + impl MockProductService { + /// Creates a new mock product service with empty storage. + fn new() -> Self { + Self { + products: Arc::new(RwLock::new(HashMap::new())), + } + } + } + + impl UseCase for MockProductService { + fn create(&self, data: CreateProduct) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + let id = Uuid::new_v4(); + let product = Product { + id, + name: data.name, + description: data.description, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + guard.insert(id, product.clone()); + Ok(product) + } + } + + fn get(&self, id: Uuid) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let guard = products.read().await; + guard.get(&id) + .cloned() + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id)))) + } + } + + fn list(&self) -> impl std::future::Future, ApplicationError>> + Send { + let products = self.products.clone(); + async move { + let guard = products.read().await; + Ok(guard.values().cloned().collect()) + } + } + + fn update(&self, id: Uuid, data: UpdateProduct) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + let product = guard.get_mut(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id))))?; + + if let Some(name) = data.name { + product.name = name; + } + if let Some(description) = data.description { + product.description = description; + } + product.updated_at = Utc::now(); + Ok(product.clone()) + } + } + + fn delete(&self, id: Uuid) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + guard.remove(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id))))?; + Ok(()) + } + } + } + + /// Creates a test Axum application with mock services. + /// + /// Returns a configured `Router` with all endpoints and mock services + /// for testing purposes. + fn create_test_app() -> Router { + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let state = AppState { + user_service: Arc::new(user_service), + product_service: Arc::new(product_service), + }; + + Router::new() + .route("/users", post(create_user::)) + .route("/users/:id", get(get_user::)) + .route("/users", get(list_users::)) + .route("/users/:id", put(update_user::)) + .route("/users/:id", delete(delete_user::)) + .route("/products", post(create_product::)) + .route("/products/:id", get(get_product::)) + .route("/products", get(list_products::)) + .route("/products/:id", put(update_product::)) + .route("/products/:id", delete(delete_product::)) + .with_state(state) + } + + /// Extracts JSON data from an HTTP response. + /// + /// Helper function for tests to deserialize response bodies. + async fn extract_json(response: Response) -> T { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + mod user_endpoints { + //! # User Endpoint Tests + //! + //! Tests for all user-related API endpoints including CRUD operations + //! and error handling scenarios. + + use super::*; + + /// Tests user creation with valid data. + #[tokio::test] + async fn test_create_user() { + let app = create_test_app(); + let create_data = json!({ + "username": "testuser", + "email": "test@example.com" + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let user: User = extract_json(response).await; + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + assert!(!user.id.is_nil()); + } + + #[tokio::test] + async fn test_get_user() { + let app = create_test_app(); + + // First create a user + let create_data = json!({ + "username": "testuser", + "email": "test@example.com" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_user: User = extract_json(create_response).await; + + // Then get the user + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", created_user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let user: User = extract_json(response).await; + assert_eq!(user.id, created_user.id); + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + } + + #[tokio::test] + async fn test_get_user_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", non_existent_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_list_users() { + let app = create_test_app(); + + // Create two users + let user1_data = json!({ + "username": "user1", + "email": "user1@example.com" + }); + + let user2_data = json!({ + "username": "user2", + "email": "user2@example.com" + }); + + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(user1_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(user2_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + // List users + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/users") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let users: Vec = extract_json(response).await; + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.username == "user1")); + assert!(users.iter().any(|u| u.username == "user2")); + } + + #[tokio::test] + async fn test_update_user() { + let app = create_test_app(); + + // First create a user + let create_data = json!({ + "username": "olduser", + "email": "old@example.com" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_user: User = extract_json(create_response).await; + + // Update the user + let update_data = json!({ + "username": "newuser", + "email": "new@example.com" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/users/{}", created_user.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let user: User = extract_json(response).await; + assert_eq!(user.id, created_user.id); + assert_eq!(user.username, "newuser"); + assert_eq!(user.email, "new@example.com"); + } + + #[tokio::test] + async fn test_update_user_partial() { + let app = create_test_app(); + + // First create a user + let create_data = json!({ + "username": "testuser", + "email": "test@example.com" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_user: User = extract_json(create_response).await; + + // Update only username + let update_data = json!({ + "username": "newuser" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/users/{}", created_user.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let user: User = extract_json(response).await; + assert_eq!(user.id, created_user.id); + assert_eq!(user.username, "newuser"); + assert_eq!(user.email, "test@example.com"); // Should remain unchanged + } + + #[tokio::test] + async fn test_update_user_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + let update_data = json!({ + "username": "newuser" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/users/{}", non_existent_id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_delete_user() { + let app = create_test_app(); + + // First create a user + let create_data = json!({ + "username": "testuser", + "email": "test@example.com" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_user: User = extract_json(create_response).await; + + // Delete the user + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/users/{}", created_user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // Verify user is deleted + let get_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", created_user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_delete_user_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/users/{}", non_existent_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } + + mod product_endpoints { + //! # Product Endpoint Tests + //! + //! Tests for all product-related API endpoints including CRUD operations + //! and error handling scenarios. + + use super::*; + + /// Tests product creation with valid data. + #[tokio::test] + async fn test_create_product() { + let app = create_test_app(); + let create_data = json!({ + "name": "Test Product", + "description": "Test Description" + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let product: Product = extract_json(response).await; + assert_eq!(product.name, "Test Product"); + assert_eq!(product.description, "Test Description"); + assert!(!product.id.is_nil()); + } + + /// Tests retrieving a product by ID. + #[tokio::test] + async fn test_get_product() { + let app = create_test_app(); + + // First create a product + let create_data = json!({ + "name": "Test Product", + "description": "Test Description" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_product: Product = extract_json(create_response).await; + + // Then get the product + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", created_product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let product: Product = extract_json(response).await; + assert_eq!(product.id, created_product.id); + assert_eq!(product.name, "Test Product"); + assert_eq!(product.description, "Test Description"); + } + + /// Tests retrieving a non-existent product. + #[tokio::test] + async fn test_get_product_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", non_existent_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + /// Tests listing all products. + #[tokio::test] + async fn test_list_products() { + let app = create_test_app(); + + // Create two products + let product1_data = json!({ + "name": "Product 1", + "description": "Description 1" + }); + + let product2_data = json!({ + "name": "Product 2", + "description": "Description 2" + }); + + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(product1_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(product2_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + // List products + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/products") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let products: Vec = extract_json(response).await; + assert_eq!(products.len(), 2); + assert!(products.iter().any(|p| p.name == "Product 1")); + assert!(products.iter().any(|p| p.name == "Product 2")); + } + + /// Tests updating a product with all fields. + #[tokio::test] + async fn test_update_product() { + let app = create_test_app(); + + // First create a product + let create_data = json!({ + "name": "Old Product", + "description": "Old Description" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_product: Product = extract_json(create_response).await; + + // Update the product + let update_data = json!({ + "name": "New Product", + "description": "New Description" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/products/{}", created_product.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let product: Product = extract_json(response).await; + assert_eq!(product.id, created_product.id); + assert_eq!(product.name, "New Product"); + assert_eq!(product.description, "New Description"); + } + + /// Tests partial product updates. + #[tokio::test] + async fn test_update_product_partial() { + let app = create_test_app(); + + // First create a product + let create_data = json!({ + "name": "Test Product", + "description": "Test Description" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_product: Product = extract_json(create_response).await; + + // Update only name + let update_data = json!({ + "name": "New Product" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/products/{}", created_product.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let product: Product = extract_json(response).await; + assert_eq!(product.id, created_product.id); + assert_eq!(product.name, "New Product"); + assert_eq!(product.description, "Test Description"); // Should remain unchanged + } + + /// Tests updating a non-existent product. + #[tokio::test] + async fn test_update_product_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + let update_data = json!({ + "name": "New Product" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/products/{}", non_existent_id)) + .header("content-type", "application/json") + .body(Body::from(update_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + /// Tests product deletion. + #[tokio::test] + async fn test_delete_product() { + let app = create_test_app(); + + // First create a product + let create_data = json!({ + "name": "Test Product", + "description": "Test Description" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let created_product: Product = extract_json(create_response).await; + + // Delete the product + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/products/{}", created_product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // Verify product is deleted + let get_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", created_product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(get_response.status(), StatusCode::NOT_FOUND); + } + + /// Tests deleting a non-existent product. + #[tokio::test] + async fn test_delete_product_not_found() { + let app = create_test_app(); + let non_existent_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/products/{}", non_existent_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } + + mod integration_tests { + //! # Integration Tests + //! + //! End-to-end tests that verify complete entity lifecycles + //! including create, read, update, and delete operations. + + use super::*; + + /// Tests complete user lifecycle (create, read, update, delete). + #[tokio::test] + async fn test_user_lifecycle() { + let app = create_test_app(); + + // Create user + let create_data = json!({ + "username": "testuser", + "email": "test@example.com" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/users") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_response.status(), StatusCode::CREATED); + let user: User = extract_json(create_response).await; + + // Get user + 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); + + // Update user + let update_data = json!({ + "username": "updateduser" + }); + + let update_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/users/{}", user.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.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, "updateduser"); + + // Delete user + 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 deletion + let verify_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/users/{}", user.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(verify_response.status(), StatusCode::NOT_FOUND); + } + + /// Tests complete product lifecycle (create, read, update, delete). + #[tokio::test] + async fn test_product_lifecycle() { + let app = create_test_app(); + + // Create product + let create_data = json!({ + "name": "Test Product", + "description": "Test Description" + }); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(create_data.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_response.status(), StatusCode::CREATED); + let product: Product = extract_json(create_response).await; + + // Get product + 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); + + // Update product + let update_data = json!({ + "name": "Updated Product" + }); + + let update_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/products/{}", product.id)) + .header("content-type", "application/json") + .body(Body::from(update_data.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, "Updated Product"); + + // Delete product + 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 deletion + let verify_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/products/{}", product.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(verify_response.status(), StatusCode::NOT_FOUND); + } + } +}