entertainarr-adapter-http 0.1.0

HTTP adapter for entertainarr
Documentation
use axum::Json;
use axum::extract::State;
use entertainarr_domain::auth::entity::{Email, Password};
use entertainarr_domain::auth::prelude::{AuthenticationService, LoginError, LoginRequest};

use crate::entity::auth::errors::{CODE_EMAIL_TOO_SHORT, CODE_PASSWORD_TOO_SHORT};
use crate::entity::auth::{AuthenticationRequestDocument, AuthenticationTokenDocument};
use crate::entity::{ApiErrorDetail, ApiResource};
use crate::server::handler::error::ApiErrorResponse;

pub async fn handle<S>(
    State(state): State<S>,
    Json(payload): Json<ApiResource<AuthenticationRequestDocument<'static>>>,
) -> Result<Json<ApiResource<AuthenticationTokenDocument>>, ApiErrorResponse>
where
    S: crate::server::prelude::ServerState,
{
    let email = Email::try_new(payload.data.attributes.email).map_err(|_| {
        ApiErrorResponse::bad_request("invalid credentials")
            .with_detail(ApiErrorDetail::new("email", CODE_EMAIL_TOO_SHORT))
    })?;
    let password = Password::try_new(payload.data.attributes.password).map_err(|_| {
        ApiErrorResponse::bad_request("invalid credentials")
            .with_detail(ApiErrorDetail::new("password", CODE_PASSWORD_TOO_SHORT))
    })?;
    state
        .authentication_service()
        .login(LoginRequest { email, password })
        .await
        .map(|res| {
            Json(ApiResource::new(AuthenticationTokenDocument {
                id: res.token,
                kind: Default::default(),
                attributes: Default::default(),
            }))
        })
        .map_err(|err| match err {
            LoginError::InvalidCredentials => ApiErrorResponse::bad_request("invalid credentials"),
            LoginError::Internal(err) => {
                tracing::error!(error = %err, error.stacktrace = ?err, "unable to login");
                ApiErrorResponse::internal()
            }
        })
}

#[cfg(test)]
mod tests {
    use axum::Json;
    use axum::extract::State;
    use axum::http::StatusCode;
    use entertainarr_domain::auth::prelude::{LoginSuccess, MockAuthenticationService};

    use crate::entity::auth::AuthenticationRequestDocument;
    use crate::server::prelude::tests::MockServerState;

    #[tokio::test]
    async fn should_succeed() {
        let mut auth_service = MockAuthenticationService::new();
        auth_service.expect_login().returning(|req| {
            assert_eq!(req.email.into_inner(), "user@example.com");
            assert_eq!(req.password.into_inner(), "password");

            Box::pin(async move {
                Ok(LoginSuccess {
                    token: String::from("token"),
                })
            })
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .build();
        let payload = AuthenticationRequestDocument::new("user@example.com", "password");
        assert!(
            super::handle(State(state), Json(crate::entity::ApiResource::new(payload)))
                .await
                .is_ok()
        );
    }

    #[tokio::test]
    async fn should_fail_validation_invalid_username() {
        let state = MockServerState::default();
        let payload = AuthenticationRequestDocument::new("  ", "password");
        let err = super::handle(State(state), Json(crate::entity::ApiResource::new(payload)))
            .await
            .unwrap_err();
        assert_eq!(err.status_code, StatusCode::BAD_REQUEST);
        assert_eq!(err.body.message, "invalid credentials");
        let detail = err.body.detail.unwrap();
        assert_eq!(detail.attribute, "email");
        assert_eq!(detail.code, "email-too-short");
    }

    #[tokio::test]
    async fn should_fail_validation_empty_password() {
        let state = MockServerState::default();
        let payload = AuthenticationRequestDocument::new("user@example.com", "          ");
        let err = super::handle(State(state), Json(crate::entity::ApiResource::new(payload)))
            .await
            .unwrap_err();
        assert_eq!(err.status_code, StatusCode::BAD_REQUEST);
        assert_eq!(err.body.message, "invalid credentials");
        let detail = err.body.detail.unwrap();
        assert_eq!(detail.attribute, "password");
        assert_eq!(detail.code, "password-too-short");
    }

    #[tokio::test]
    async fn should_fail_validation_invalid_password() {
        let state = MockServerState::default();
        let payload = AuthenticationRequestDocument::new("user@example.com", "foo");
        let err = super::handle(State(state), Json(crate::entity::ApiResource::new(payload)))
            .await
            .unwrap_err();
        assert_eq!(err.status_code, StatusCode::BAD_REQUEST);
        assert_eq!(err.body.message, "invalid credentials");
        let detail = err.body.detail.unwrap();
        assert_eq!(detail.attribute, "password");
        assert_eq!(detail.code, "password-too-short");
    }
}

#[cfg(test)]
mod integration {
    use entertainarr_domain::auth::prelude::{LoginSuccess, MockAuthenticationService};
    use tower::ServiceExt;

    use crate::server::prelude::tests::MockServerState;

    #[tokio::test]
    async fn should_answer() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service.expect_login().returning(|req| {
            assert_eq!(req.email.into_inner(), "user@example.com");
            assert_eq!(req.password.into_inner(), "password");

            Box::pin(async move {
                Ok(LoginSuccess {
                    token: String::from("token"),
                })
            })
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/auth/login")
                    .method(axum::http::Method::POST)
                    .header("Content-Type", "application/json")
                    .body(axum::body::Body::from(
                        r#"{"data":{"attributes":{"email":"user@example.com","password":"password"},"type":"authentication-requests"}}"#,
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::OK);
    }
}