feat(api): metadata endpoints for library titles
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT tmdb_id FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "tmdb_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT tmdb_id FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "tmdb_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba"
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||
|
||||
mod health;
|
||||
mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
@@ -24,6 +25,7 @@ use utoipa_axum::routes;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
pub use health::{Check, Health, HealthReport, Status};
|
||||
pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata};
|
||||
pub use movies::{
|
||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||
};
|
||||
@@ -83,6 +85,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(movies::attention))
|
||||
.routes(routes!(movies::list_owners))
|
||||
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
||||
.routes(routes!(metadata::movie_metadata))
|
||||
.routes(routes!(series::list, series::create))
|
||||
.routes(routes!(series::get, series::update, series::delete))
|
||||
.routes(routes!(series::seasons, series::create_season))
|
||||
@@ -95,6 +98,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(metadata::series_metadata))
|
||||
.routes(routes!(series::list_owners))
|
||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||
.routes(routes!(owners::list, owners::create))
|
||||
@@ -316,6 +320,8 @@ mod tests {
|
||||
("/api/movies/{movie_id}/search", "post"),
|
||||
("/api/movies/{movie_id}/releases", "get"),
|
||||
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
||||
("/api/movies/{movie_id}/metadata", "get"),
|
||||
("/api/series/{series_id}/metadata", "get"),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/search",
|
||||
"post",
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Title detail metadata for the §9.6 pages (#146). One endpoint per kind,
|
||||
//! one upstream call each through `arr-meta`'s detail methods, cached 24h and
|
||||
//! never persisted. Library titles only: search rows already carry what a
|
||||
//! results list needs.
|
||||
|
||||
use arr_meta::{MovieDetail, SeriesDetail};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::movies::{pool, ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One of the top-billed cast members on a detail page.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataCastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
/// Path fragment exactly as TMDB sends it (§9.6): the browser composes
|
||||
/// the URL and picks the size.
|
||||
pub profile_path: Option<String>,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataTrailer {
|
||||
pub youtube_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Rich detail for one library movie's §9.6 page. Image paths are fragments,
|
||||
/// never composed URLs; the Rotten Tomatoes link is deliberately absent — it
|
||||
/// is a browser-built search URL, not an identifier this service holds.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MovieMetadata {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
/// Minutes.
|
||||
pub runtime: Option<u32>,
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
/// Rich detail for one library series' §9.6 page.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeriesMetadata {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
/// Episode length in minutes.
|
||||
pub runtime: Option<u32>,
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
impl From<MovieDetail> for MovieMetadata {
|
||||
fn from(detail: MovieDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: detail.tmdb_id,
|
||||
overview: detail.overview,
|
||||
tagline: detail.tagline,
|
||||
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
|
||||
runtime: detail.runtime,
|
||||
status: detail.status,
|
||||
poster_path: detail.poster_path,
|
||||
backdrop_path: detail.backdrop_path,
|
||||
vote_average: detail.vote_average,
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
imdb_id: detail.imdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SeriesDetail> for SeriesMetadata {
|
||||
fn from(detail: SeriesDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: detail.tmdb_id,
|
||||
overview: detail.overview,
|
||||
tagline: detail.tagline,
|
||||
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
|
||||
runtime: detail.episode_runtime,
|
||||
status: detail.status,
|
||||
poster_path: detail.poster_path,
|
||||
backdrop_path: detail.backdrop_path,
|
||||
vote_average: detail.vote_average,
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
tvdb_id: detail.tvdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::CastMember> for MetadataCastMember {
|
||||
fn from(member: arr_meta::CastMember) -> Self {
|
||||
Self {
|
||||
tmdb_id: member.tmdb_id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: member.profile_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::Video> for MetadataTrailer {
|
||||
fn from(video: arr_meta::Video) -> Self {
|
||||
Self {
|
||||
youtube_key: video.key,
|
||||
name: video.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tmdb_id(raw: i64) -> Result<u32, ApiError> {
|
||||
u32::try_from(raw).map_err(|_| ApiError::Invalid("stored tmdb_id is out of range".into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/metadata", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
responses(
|
||||
(status = 200, body = MovieMetadata),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn movie_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<MovieMetadata>, ApiError> {
|
||||
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM movies WHERE id = ?", id)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let detail = tmdb
|
||||
.movie_detail(tmdb_id(tmdb_row)?)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
Ok(Json(detail.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/metadata", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
responses(
|
||||
(status = 200, body = SeriesMetadata),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn series_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<SeriesMetadata>, ApiError> {
|
||||
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM series WHERE id = ?", id)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if matches!(error, sqlx::Error::RowNotFound) {
|
||||
ApiError::SeriesNotFound
|
||||
} else {
|
||||
error.into()
|
||||
}
|
||||
})?;
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let detail = tmdb
|
||||
.series_detail(tmdb_id(tmdb_row)?)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
Ok(Json(detail.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// App against a mocked TMDB, with a real migrated database so library
|
||||
/// rows exist to resolve local ids against.
|
||||
async fn application(tmdb: &MockServer) -> (tempfile::TempDir, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
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")
|
||||
.with_database(database);
|
||||
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") });
|
||||
(dir, format!("http://{address}"))
|
||||
}
|
||||
|
||||
async fn add_movie(base: &str) -> serde_json::Value {
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||
"original_language": "en", "root_id": 1
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie");
|
||||
assert_eq!(response.status(), 201);
|
||||
response.json().await.expect("movie json")
|
||||
}
|
||||
|
||||
async fn add_series(base: &str) -> serde_json::Value {
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("roots json");
|
||||
let tv_root = roots
|
||||
.iter()
|
||||
.find(|root| root["kind"] == "tv")
|
||||
.and_then(|root| root["id"].as_i64())
|
||||
.expect("a seeded TV root");
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 82_728, "title": "The Last of Us",
|
||||
"original_language": "en", "root_id": tv_root
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create series");
|
||||
assert_eq!(response.status(), 201);
|
||||
response.json().await.expect("series json")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_metadata_resolves_through_one_detail_call() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"overview": "Paul Atreides unites with the Fremen.",
|
||||
"tagline": "Long live the fighters.",
|
||||
"genres": [{"id": 878, "name": "Science Fiction"}, {"id": 12, "name": "Adventure"}],
|
||||
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"poster_path": "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg",
|
||||
"vote_average": 8.417,
|
||||
"vote_count": 3_512,
|
||||
"homepage": "https://www.duneparttwo.com",
|
||||
"status": "Released",
|
||||
"runtime": 166,
|
||||
"imdb_id": "tt15239678",
|
||||
"credits": {"cast": [
|
||||
{"id": 9_058_792, "name": "Timothée Chalamet", "character": "Paul Atreides",
|
||||
"profile_path": "/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg", "order": 0},
|
||||
{"id": 1_320_062, "name": "Zendaya", "character": "Chani",
|
||||
"profile_path": "/ultRzqcGdWXWX3rjg1up9HcsoGi.jpg", "order": 1}
|
||||
]},
|
||||
"videos": {"results": [
|
||||
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Fan Trailer", "official": false},
|
||||
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Dune: Part Two | Official Trailer", "official": true}
|
||||
]}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
let movie = add_movie(&base).await;
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
|
||||
let body: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
|
||||
assert_eq!(body["tmdb_id"], 693_134);
|
||||
assert_eq!(body["overview"], "Paul Atreides unites with the Fremen.");
|
||||
assert_eq!(body["tagline"], "Long live the fighters.");
|
||||
assert_eq!(body["genres"][0], "Science Fiction");
|
||||
assert_eq!(body["runtime"], 166);
|
||||
assert_eq!(body["status"], "Released");
|
||||
assert_eq!(body["imdb_id"], "tt15239678");
|
||||
assert_eq!(body["vote_average"], serde_json::json!(8.417));
|
||||
assert_eq!(body["vote_count"], 3_512);
|
||||
// §9.6: path fragments exactly as TMDB sends them, never composed URLs.
|
||||
assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg");
|
||||
assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg");
|
||||
assert_eq!(
|
||||
body["cast"][0]["profile_path"],
|
||||
"/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg"
|
||||
);
|
||||
assert_eq!(body["cast"][0]["character"], "Paul Atreides");
|
||||
assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_metadata_carries_the_tvdb_id_and_episode_runtime() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"overview": "Twenty years after civilization fell.",
|
||||
"tagline": "When you're lost in the darkness, look for the light.",
|
||||
"genres": [{"id": 18, "name": "Drama"}],
|
||||
"backdrop_path": "/eMhDKZscBd07OLpAeeAyu3N3U8c.jpg",
|
||||
"poster_path": "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
|
||||
"vote_average": 8.5,
|
||||
"vote_count": 2_911,
|
||||
"homepage": "",
|
||||
"status": "Returning Series",
|
||||
"episode_run_time": [55, 57],
|
||||
"external_ids": {"tvdb_id": 392_256},
|
||||
"credits": {"cast": [
|
||||
{"id": 1_320_062, "name": "Pedro Pascal", "character": "Joel Miller",
|
||||
"profile_path": "/zEmkctzuGBEbxFfOtpTAg9jtkTu.jpg", "order": 0}
|
||||
]},
|
||||
"videos": {"results": [
|
||||
{"key": "uLtkt8Bonuu", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Official Trailer", "official": true}
|
||||
]}
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
let series = add_series(&base).await;
|
||||
let series_id = series["id"].as_i64().expect("series id");
|
||||
|
||||
let body: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/metadata"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
|
||||
assert_eq!(body["tmdb_id"], 82_728);
|
||||
assert_eq!(body["tvdb_id"], 392_256);
|
||||
// TMDB sends a list of episode lengths; the API reports one runtime.
|
||||
assert_eq!(body["runtime"], 55);
|
||||
assert_eq!(body["status"], "Returning Series");
|
||||
// TMDB uses "" where null is meant; it stays null on the way out.
|
||||
assert_eq!(body["homepage"], serde_json::Value::Null);
|
||||
assert_eq!(body["poster_path"], "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg");
|
||||
assert_eq!(body["trailer"]["youtube_key"], "uLtkt8Bonuu");
|
||||
}
|
||||
|
||||
/// An id that is not in the library is a 404 before TMDB is asked —
|
||||
/// search rows are addressed through #144's trailer endpoint instead.
|
||||
#[tokio::test]
|
||||
async fn an_unknown_local_id_is_a_forty_forty_without_touching_tmdb() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
|
||||
let movie = reqwest::get(format!("{base}/api/movies/99/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(movie.status(), 404);
|
||||
assert_eq!(
|
||||
movie.json::<serde_json::Value>().await.expect("json")["error"],
|
||||
"movie not found"
|
||||
);
|
||||
|
||||
let series = reqwest::get(format!("{base}/api/series/99/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(series.status(), 404);
|
||||
assert_eq!(
|
||||
series.json::<serde_json::Value>().await.expect("json")["error"],
|
||||
"series not found"
|
||||
);
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_tmdb_key_is_a_fifty_three_like_the_rest_of_the_surface() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
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 movie = add_movie(&base).await;
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user