entertainarr-adapter-http 0.1.0

HTTP adapter for entertainarr
Documentation
use std::collections::HashSet;

use axum::Json;
use axum::extract::{Path, State};
use entertainarr_domain::podcast::entity::FindPodcastResponse;
use entertainarr_domain::podcast::prelude::PodcastService;
use serde_qs::axum::QsQuery;

use crate::entity::podcast::{
    PodcastAttributes, PodcastDocument, PodcastInclude, PodcastRelation, PodcastRelationships,
};
use crate::entity::podcast_subscription::{
    PodcastSubscriptionAttributes, PodcastSubscriptionDocument, PodcastSubscriptionEntity,
};
use crate::entity::prelude::FindQueryParams;
use crate::entity::task::TaskEntity;
use crate::entity::{ApiResource, Couple, Relation};
use crate::server::extractor::user::CurrentUser;
use crate::server::handler::error::ApiErrorResponse;
use crate::server::handler::prelude::FromDomainResponse;

pub async fn handle<S>(
    State(state): State<S>,
    CurrentUser(user_id): CurrentUser,
    Path(podcast_id): Path<u64>,
    QsQuery(params): QsQuery<FindQueryParams<PodcastInclude>>,
) -> Result<Json<ApiResource<PodcastDocument, PodcastRelation>>, ApiErrorResponse>
where
    S: crate::server::prelude::ServerState,
{
    let response = state
        .podcast_service()
        .find_podcast_by_id(user_id, podcast_id)
        .await
        .map_err(|err| {
            tracing::error!(error = ?err, "unable to fetch podcast");
            ApiErrorResponse::internal()
        })?
        .ok_or_else(|| ApiErrorResponse::not_found("podcast not found"))?;

    Ok(Json(ApiResource::from_response(response, params.include)))
}

impl FromDomainResponse<FindPodcastResponse, PodcastInclude>
    for ApiResource<PodcastDocument, PodcastRelation>
{
    fn from_response(response: FindPodcastResponse, required: HashSet<PodcastInclude>) -> Self {
        let data = PodcastDocument {
            id: response.podcast.id,
            kind: Default::default(),
            attributes: PodcastAttributes::from(response.podcast),
            relationships: PodcastRelationships {
                subscription: Relation {
                    data: response.subscription.as_ref().map(|sub| {
                        PodcastSubscriptionEntity::new(Couple(sub.podcast_id, sub.user_id))
                    }),
                    meta: None,
                },
                synchronization: Relation {
                    data: response
                        .synchronization
                        .as_ref()
                        .map(|sync| TaskEntity::new(sync.id)),
                    meta: (),
                },
            },
        };

        let mut includes = Vec::with_capacity(2);

        if required.contains(&PodcastInclude::Subscription)
            && let Some(sub) = response.subscription
        {
            includes.push(PodcastRelation::PocastSubscription(
                PodcastSubscriptionDocument {
                    id: Couple(sub.podcast_id, sub.user_id),
                    kind: Default::default(),
                    attributes: PodcastSubscriptionAttributes {
                        min_duration: sub.min_duration,
                        max_duration: sub.max_duration,
                        created_at: sub.created_at,
                    },
                },
            ));
        }

        if required.contains(&PodcastInclude::Synchronization)
            && let Some(sync) = response.synchronization
        {
            includes.push(PodcastRelation::Task(sync.into()));
        }
        ApiResource { data, includes }
    }
}

#[cfg(test)]
mod integration {

    use std::collections::HashMap;

    use chrono::Utc;
    use entertainarr_domain::auth::entity::Profile;
    use entertainarr_domain::auth::prelude::MockAuthenticationService;
    use entertainarr_domain::podcast::entity::{ListPodcastResponse, Podcast};
    use entertainarr_domain::podcast::prelude::MockPodcastService;
    use tower::ServiceExt;

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

    #[tokio::test]
    async fn should_fail_if_anonymous() {
        let router = crate::server::handler::create();
        let state = MockServerState::builder().build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Content-Type", "application/json")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn should_fail_if_token_malformed() {
        let router = crate::server::handler::create();
        let state = MockServerState::builder().build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Content-Type", "application/json")
                    .header("Authorization", "nope")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn should_fail_if_token_expired() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service.expect_verify().returning(|token| {
            assert_eq!(token, "token");
            Box::pin(
                async move { Err(entertainarr_domain::auth::prelude::VerifyError::ExpiredToken) },
            )
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Content-Type", "application/json")
                    .header("Authorization", "Bearer token")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn should_fail_if_token_invalid() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service.expect_verify().returning(|token| {
            assert_eq!(token, "token");
            Box::pin(
                async move { Err(entertainarr_domain::auth::prelude::VerifyError::InvalidToken) },
            )
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Content-Type", "application/json")
                    .header("Authorization", "Bearer token")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn should_fail_if_token_failed_decoding() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service.expect_verify().returning(|token| {
            assert_eq!(token, "token");
            Box::pin(async move {
                Err(entertainarr_domain::auth::prelude::VerifyError::Internal(
                    anyhow::anyhow!("oops"),
                ))
            })
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Content-Type", "application/json")
                    .header("Authorization", "Bearer token")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn should_answer_if_autheticated() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service
            .expect_verify()
            .returning(|_| Box::pin(async { Ok(Profile { id: 1 }) }));
        let mut podcast_service = MockPodcastService::new();
        podcast_service.expect_list_podcast().returning(|_| {
            Box::pin(async {
                Ok(ListPodcastResponse {
                    podcasts: vec![Podcast {
                        id: 1,
                        title: "title".into(),
                        feed_url: "feed".into(),
                        image_url: None,
                        language: None,
                        website: None,
                        description: None,
                        created_at: Utc::now(),
                        updated_at: Utc::now(),
                    }],
                    subscriptions: HashMap::default(),
                    synchronizations: HashMap::default(),
                })
            })
        });
        let state = MockServerState::builder()
            .authentication(auth_service)
            .podcast(podcast_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Authorization", "Bearer fake")
                    .header("Content-Type", "application/json")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::OK);
    }

    #[tokio::test]
    async fn should_fail_if_service_fails() {
        let router = crate::server::handler::create();
        let mut auth_service = MockAuthenticationService::new();
        auth_service
            .expect_verify()
            .returning(|_| Box::pin(async { Ok(Profile { id: 1 }) }));
        let mut podcast_service = MockPodcastService::new();
        podcast_service
            .expect_list_podcast()
            .returning(|_| Box::pin(async { Err(anyhow::anyhow!("oops")) }));
        let state = MockServerState::builder()
            .authentication(auth_service)
            .podcast(podcast_service)
            .build();
        let res = router
            .with_state(state)
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/users/me/podcasts")
                    .method(axum::http::Method::GET)
                    .header("Authorization", "Bearer fake")
                    .header("Content-Type", "application/json")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
    }
}