road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
use road_runner_common::error::{AppError, ErrorCode};
use road_runner_common::response::ApiErrorDetail;

#[test]
fn error_code_wire_strings_are_stable() {
    // Arrange / Act / Assert
    assert_eq!(ErrorCode::Validation.as_str(), "VALIDATION_ERROR");
    assert_eq!(ErrorCode::Unauthorized.as_str(), "UNAUTHORIZED");
    assert_eq!(ErrorCode::Forbidden.as_str(), "FORBIDDEN");
    assert_eq!(ErrorCode::NotFound.as_str(), "NOT_FOUND");
    assert_eq!(ErrorCode::Conflict.as_str(), "CONFLICT");
    assert_eq!(ErrorCode::RateLimited.as_str(), "RATE_LIMITED");
    assert_eq!(ErrorCode::Internal.as_str(), "INTERNAL_ERROR");
    assert_eq!(ErrorCode::External.as_str(), "EXTERNAL_ERROR");
    assert_eq!(ErrorCode::ServiceUnavailable.as_str(), "SERVICE_UNAVAILABLE");
}

#[test]
fn app_error_code_and_public_message_cover_all_variants() {
    // Arrange
    let cases = vec![
        (AppError::validation("invalid", vec![]), ErrorCode::Validation, "invalid"),
        (AppError::Unauthorized { message: "login".to_string() }, ErrorCode::Unauthorized, "login"),
        (AppError::Forbidden { message: "no".to_string() }, ErrorCode::Forbidden, "no"),
        (AppError::NotFound { message: "missing".to_string() }, ErrorCode::NotFound, "missing"),
        (AppError::Conflict { message: "duplicate".to_string() }, ErrorCode::Conflict, "duplicate"),
        (AppError::RateLimited { message: "slow".to_string() }, ErrorCode::RateLimited, "slow"),
        (AppError::internal("secret", None), ErrorCode::Internal, "Internal server error"),
        (AppError::external("upstream detail", None), ErrorCode::External, "Upstream service error"),
        (
            AppError::ServiceUnavailable { message: "down".to_string() },
            ErrorCode::ServiceUnavailable,
            "down",
        ),
    ];

    // Act / Assert
    for (error, code, public_message) in cases {
        assert_eq!(error.code().as_str(), code.as_str());
        assert_eq!(error.public_message(), public_message);
    }
}

#[test]
fn validation_constructor_preserves_details_in_api_response() {
    // Arrange
    let error = AppError::validation(
        "invalid request",
        vec![ApiErrorDetail::new("FIELD", "bad").with_field("name")],
    );

    // Act
    let response = error.to_api_response("rid-validation");

    // Assert
    assert!(!response.success);
    assert_eq!(response.meta.request_id, "rid-validation");
    assert_eq!(response.errors.len(), 1);
    assert_eq!(response.errors[0].code, "FIELD");
    assert_eq!(response.errors[0].field.as_deref(), Some("name"));
}

#[test]
fn validation_without_details_uses_error_code_and_message() {
    // Arrange
    let error = AppError::validation("missing body", vec![]);

    // Act
    let response = error.to_api_response("rid-empty-validation");

    // Assert
    assert_eq!(response.errors.len(), 1);
    assert_eq!(response.errors[0].code, "VALIDATION_ERROR");
    assert_eq!(response.errors[0].message, "missing body");
}

#[test]
fn non_validation_api_response_contains_single_public_error() {
    // Arrange
    let error = AppError::internal("database password leaked here", None);

    // Act
    let response = error.to_api_response("rid-internal");

    // Assert
    assert!(!response.success);
    assert_eq!(response.errors.len(), 1);
    assert_eq!(response.errors[0].code, "INTERNAL_ERROR");
    assert_eq!(response.errors[0].message, "Internal server error");
}

#[cfg(feature = "web")]
mod web {
    use actix_web::http::StatusCode;
    use actix_web::ResponseError;
    use road_runner_common::error::AppError;

    #[test]
    fn http_status_maps_all_error_variants() {
        // Arrange
        let cases = vec![
            (AppError::validation("invalid", vec![]), StatusCode::BAD_REQUEST),
            (AppError::Unauthorized { message: "login".to_string() }, StatusCode::UNAUTHORIZED),
            (AppError::Forbidden { message: "no".to_string() }, StatusCode::FORBIDDEN),
            (AppError::NotFound { message: "missing".to_string() }, StatusCode::NOT_FOUND),
            (AppError::Conflict { message: "duplicate".to_string() }, StatusCode::CONFLICT),
            (AppError::RateLimited { message: "slow".to_string() }, StatusCode::TOO_MANY_REQUESTS),
            (AppError::internal("secret", None), StatusCode::INTERNAL_SERVER_ERROR),
            (AppError::external("upstream", None), StatusCode::BAD_GATEWAY),
            (
                AppError::ServiceUnavailable { message: "down".to_string() },
                StatusCode::SERVICE_UNAVAILABLE,
            ),
        ];

        // Act / Assert
        for (error, status) in cases {
            assert_eq!(error.http_status(), status);
            assert_eq!(error.status_code(), status);
        }
    }

    #[test]
    fn error_response_uses_status_and_json_content_type() {
        // Arrange
        let error = AppError::Forbidden { message: "blocked".to_string() };

        // Act
        let response = error.error_response();

        // Assert
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response.headers().get("content-type").unwrap().to_str().unwrap(),
            "application/json"
        );
    }
}