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);
}
}