use axum::extract::{Query, State}; use axum::Json; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use crate::movies::{ApiError, ErrorBody}; use crate::search::{tmdb_client, upstream_error}; use crate::state::AppState; #[derive(Debug, Deserialize, IntoParams)] pub struct TrailerQuery { kind: TrailerKind, tmdb_id: u32, } /// Which namespace the TMDB id names. Movie and series ids are independent /// numbering spaces at TMDB. #[derive(Debug, Clone, Copy, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum TrailerKind { Movie, Tv, } /// The one trailer worth showing (§9.6), resolved on click rather than /// prefetched: TMDB's search responses carry no videos. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Trailer { pub youtube_key: String, pub name: String, } #[utoipa::path( get, path = "/api/trailer", tag = "search", params(TrailerQuery), responses( (status = 200, body = Trailer), (status = 404, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn trailer( State(state): State, Query(query): Query, ) -> Result, ApiError> { let tmdb = tmdb_client(&state)?; let video = match query.kind { TrailerKind::Movie => tmdb.movie_videos(query.tmdb_id).await, TrailerKind::Tv => tmdb.series_videos(query.tmdb_id).await, } .map_err(|error| upstream_error(&error))?; match video { Some(video) => Ok(Json(Trailer { youtube_key: video.key, name: video.name, })), None => Err(ApiError::NoTrailer), } } #[cfg(test)] mod tests { use super::*; use crate::{router, Upstreams}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// Serve the app against a mocked TMDB. No database: the handler never /// touches one, because the chip addresses titles that have no local row. async fn application(tmdb: &MockServer) -> String { let state = AppState::new( Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into()) .with_tmdb_url(tmdb.uri()) .with_tmdb_api_key(Some("tmdb-key".into())), ) .expect("state"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") }); format!("http://{address}") } #[tokio::test] async fn a_movie_trailer_resolves_through_the_videos_call() { let tmdb = MockServer::start().await; // One request, videos only — §9.6 keeps the click cost at one call. Mock::given(method("GET")) .and(path("/movie/693134/videos")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 693_134, "results": [ {"key": "fan_edit", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false}, {"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true} ] }))) .expect(1) .mount(&tmdb) .await; let base = application(&tmdb).await; let response: serde_json::Value = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134")) .await .expect("request") .json() .await .expect("json"); assert_eq!(response["youtube_key"], "Way9Dexny3w"); assert_eq!(response["name"], "Official Trailer"); tmdb.verify().await; } #[tokio::test] async fn a_series_trailer_resolves_through_the_same_rule() { let tmdb = MockServer::start().await; Mock::given(method("GET")) .and(path("/tv/82728/videos")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 82_728, "results": [ {"key": "abc123", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": true} ] }))) .mount(&tmdb) .await; let base = application(&tmdb).await; let response: serde_json::Value = reqwest::get(format!("{base}/api/trailer?kind=tv&tmdb_id=82728")) .await .expect("request") .json() .await .expect("json"); assert_eq!(response["youtube_key"], "abc123"); assert_eq!(response["name"], "Series Teaser"); } /// A title TMDB knows but has no `YouTube` trailer for is an ordinary /// outcome: the browser renders "no trailer", so 404, never a 5xx. #[tokio::test] async fn a_title_without_a_trailer_is_a_forty_forty() { let tmdb = MockServer::start().await; Mock::given(method("GET")) .and(path("/movie/1/videos")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1, "results": [ {"key": "clip", "site": "Vimeo", "type": "Clip", "name": "A Clip", "official": true} ] }))) .mount(&tmdb) .await; let base = application(&tmdb).await; let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1")) .await .expect("request"); assert_eq!(response.status(), 404); let body: serde_json::Value = response.json().await.expect("json"); assert_eq!(body["error"], "no trailer"); } #[tokio::test] async fn an_upstream_outage_is_not_mistaken_for_no_trailer() { let tmdb = MockServer::start().await; Mock::given(method("GET")) .and(path("/movie/1/videos")) .respond_with(ResponseTemplate::new(500)) .mount(&tmdb) .await; let base = application(&tmdb).await; let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1")) .await .expect("request"); assert_eq!(response.status(), 503); } #[tokio::test] async fn no_tmdb_key_behaves_like_the_rest_of_the_search_surface() { let state = AppState::new(Upstreams::new( "http://127.0.0.1:1".into(), "http://127.0.0.1:1".into(), )) .expect("state"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") }); let base = format!("http://{address}"); let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134")) .await .expect("request"); assert_eq!(response.status(), 503); } }