sharenet/backend/crates/api/src/lib.rs

1516 lines
50 KiB
Rust

//! # 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<User> and UseCase<Product>
//! #[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.
*
* 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 <continuist02@gmail.com>
*/
use std::net::SocketAddr;
use std::sync::Arc;
use application::UseCase;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post, put},
Json, Router,
};
use domain::{CreateProduct, CreateUser, Product, UpdateProduct, UpdateUser, User};
use tower_http::trace::TraceLayer;
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<U, P> {
user_service: Arc<U>,
product_service: Arc<P>,
}
impl<U, P> Clone for AppState<U, P>
where
U: Clone,
P: Clone,
{
fn clone(&self) -> Self {
Self {
user_service: self.user_service.clone(),
product_service: self.product_service.clone(),
}
}
}
/// 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<U, P>(addr: SocketAddr, user_service: U, product_service: P)
where
U: UseCase<User> + Clone + Send + Sync + 'static,
P: UseCase<Product> + Clone + Send + Sync + 'static,
{
tracing_subscriber::registry()
.with(EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let state = AppState {
user_service: Arc::new(user_service),
product_service: Arc::new(product_service),
};
// Configure CORS
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/users", post(create_user::<U>))
.route("/users/:id", get(get_user::<U>))
.route("/users", get(list_users::<U>))
.route("/users/:id", put(update_user::<U>))
.route("/users/:id", delete(delete_user::<U>))
.route("/products", post(create_product::<P>))
.route("/products/:id", get(get_product::<P>))
.route("/products", get(list_products::<P>))
.route("/products/:id", put(update_product::<P>))
.route("/products/:id", delete(delete_product::<P>))
.layer(cors)
.layer(TraceLayer::new_for_http())
.with_state(state);
tracing::info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
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<U>(
State(state): State<AppState<U, impl UseCase<Product>>>,
Json(data): Json<CreateUser>,
) -> impl IntoResponse
where
U: UseCase<User>,
{
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(),
}
}
/// 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<U>(
State(state): State<AppState<U, impl UseCase<Product>>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse
where
U: UseCase<User>,
{
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(),
}
}
/// Lists all users.
///
/// # Response
/// - `200 OK` - List of all users
/// - `500 Internal Server Error` - Service error
async fn list_users<U>(
State(state): State<AppState<U, impl UseCase<Product>>>,
) -> impl IntoResponse
where
U: UseCase<User>,
{
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(),
}
}
/// 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<U>(
State(state): State<AppState<U, impl UseCase<Product>>>,
Path(id): Path<Uuid>,
Json(data): Json<UpdateUser>,
) -> impl IntoResponse
where
U: UseCase<User>,
{
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(),
}
}
/// 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<U>(
State(state): State<AppState<U, impl UseCase<Product>>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse
where
U: UseCase<User>,
{
match state.user_service.delete(id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
/// 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<P>(
State(state): State<AppState<impl UseCase<User>, P>>,
Json(data): Json<CreateProduct>,
) -> impl IntoResponse
where
P: UseCase<Product>,
{
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(),
}
}
/// 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<P>(
State(state): State<AppState<impl UseCase<User>, P>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse
where
P: UseCase<Product>,
{
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(),
}
}
/// Lists all products.
///
/// # Response
/// - `200 OK` - List of all products
/// - `500 Internal Server Error` - Service error
async fn list_products<P>(
State(state): State<AppState<impl UseCase<User>, P>>,
) -> impl IntoResponse
where
P: UseCase<Product>,
{
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(),
}
}
/// 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<P>(
State(state): State<AppState<impl UseCase<User>, P>>,
Path(id): Path<Uuid>,
Json(data): Json<UpdateProduct>,
) -> impl IntoResponse
where
P: UseCase<Product>,
{
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(),
}
}
/// 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<P>(
State(state): State<AppState<impl UseCase<User>, P>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse
where
P: UseCase<Product>,
{
match state.product_service.delete(id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
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 serde_json::json;
use tower::ServiceExt;
/// Mock user service for testing.
///
/// Implements the `UseCase<User>` trait using an in-memory HashMap
/// for storing test data. Provides thread-safe access via `RwLock`.
#[derive(Clone)]
struct MockUserService {
users: Arc<RwLock<HashMap<Uuid, User>>>,
}
impl MockUserService {
/// Creates a new mock user service with empty storage.
fn new() -> Self {
Self {
users: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl UseCase<User> for MockUserService {
fn create(&self, data: CreateUser) -> impl std::future::Future<Output = Result<User, ApplicationError>> + Send {
let users = self.users.clone();
async move {
let mut guard = users.write().await;
let id = Uuid::new_v4();
let user = User::new(id, data.username().to_string(), data.email().to_string())?;
guard.insert(id, user.clone());
Ok(user)
}
}
fn get(&self, id: Uuid) -> impl std::future::Future<Output = Result<User, ApplicationError>> + 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<Output = Result<Vec<User>, 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<Output = Result<User, ApplicationError>> + 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.set_username(username.to_string())?;
}
if let Some(email) = data.email() {
user.set_email(email.to_string())?;
}
Ok(user.clone())
}
}
fn delete(&self, id: Uuid) -> impl std::future::Future<Output = Result<(), ApplicationError>> + 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<Product>` trait using an in-memory HashMap
/// for storing test data. Provides thread-safe access via `RwLock`.
#[derive(Clone)]
struct MockProductService {
products: Arc<RwLock<HashMap<Uuid, Product>>>,
}
impl MockProductService {
/// Creates a new mock product service with empty storage.
fn new() -> Self {
Self {
products: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl UseCase<Product> for MockProductService {
fn create(&self, data: CreateProduct) -> impl std::future::Future<Output = Result<Product, ApplicationError>> + Send {
let products = self.products.clone();
async move {
let mut guard = products.write().await;
let id = Uuid::new_v4();
let product = Product::new(id, data.name().to_string(), data.description().to_string())?;
guard.insert(id, product.clone());
Ok(product)
}
}
fn get(&self, id: Uuid) -> impl std::future::Future<Output = Result<Product, ApplicationError>> + 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<Output = Result<Vec<Product>, 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<Output = Result<Product, ApplicationError>> + 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.set_name(name.to_string())?;
}
if let Some(description) = data.description() {
product.set_description(description.to_string())?;
}
Ok(product.clone())
}
}
fn delete(&self, id: Uuid) -> impl std::future::Future<Output = Result<(), ApplicationError>> + 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::<MockUserService>))
.route("/users/:id", get(get_user::<MockUserService>))
.route("/users", get(list_users::<MockUserService>))
.route("/users/:id", put(update_user::<MockUserService>))
.route("/users/:id", delete(delete_user::<MockUserService>))
.route("/products", post(create_product::<MockProductService>))
.route("/products/:id", get(get_product::<MockProductService>))
.route("/products", get(list_products::<MockProductService>))
.route("/products/:id", put(update_product::<MockProductService>))
.route("/products/:id", delete(delete_product::<MockProductService>))
.with_state(state)
}
/// Extracts JSON data from an HTTP response.
///
/// Helper function for tests to deserialize response bodies.
async fn extract_json<T: serde::de::DeserializeOwned>(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<User> = 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<Product> = 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);
}
}
}