diff --git a/crates/arr-subs/tests/fixtures/search_episode.json b/crates/arr-subs/tests/fixtures/search_episode.json new file mode 100644 index 0000000..b856df2 --- /dev/null +++ b/crates/arr-subs/tests/fixtures/search_episode.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "id": "eee555", + "language": "en", + "flags": [], + "releases": ["Show.S01E02.1080p.WEB-DL-TEAM"], + "custom_releases": [] + } + ], + "page": 1, + "all_pages": 1 +} diff --git a/crates/arr-subs/tests/fixtures/search_malformed.json b/crates/arr-subs/tests/fixtures/search_malformed.json new file mode 100644 index 0000000..61a6ae4 --- /dev/null +++ b/crates/arr-subs/tests/fixtures/search_malformed.json @@ -0,0 +1 @@ +{"oops": true} diff --git a/crates/arr-subs/tests/fixtures/search_movie_page1.json b/crates/arr-subs/tests/fixtures/search_movie_page1.json new file mode 100644 index 0000000..67b12fb --- /dev/null +++ b/crates/arr-subs/tests/fixtures/search_movie_page1.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": "aaa111", + "language": "pt-BR", + "flags": ["hearing_impaired"], + "releases": ["Movie.2024.1080p.WEB-DL-GROUP"], + "custom_releases": [] + }, + { + "id": "bbb222", + "language": "en", + "flags": [], + "releases": ["Movie.2024.1080p.WEB-DL-GROUP"], + "custom_releases": [] + } + ], + "page": 1, + "all_pages": 2 +} diff --git a/crates/arr-subs/tests/fixtures/search_movie_page2.json b/crates/arr-subs/tests/fixtures/search_movie_page2.json new file mode 100644 index 0000000..0eda058 --- /dev/null +++ b/crates/arr-subs/tests/fixtures/search_movie_page2.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": "aaa111", + "language": "pt-BR", + "flags": ["hearing_impaired"], + "releases": ["Movie.2024.1080p.WEB-DL-GROUP"], + "custom_releases": [] + }, + { + "id": "ccc333", + "language": "pt-PT", + "flags": [], + "releases": ["Movie.2024.720p.BluRay-OTHERGRP"], + "custom_releases": [] + } + ], + "page": 2, + "all_pages": 2 +} diff --git a/crates/arr-subs/tests/podnapisi.rs b/crates/arr-subs/tests/podnapisi.rs new file mode 100644 index 0000000..7db1674 --- /dev/null +++ b/crates/arr-subs/tests/podnapisi.rs @@ -0,0 +1,284 @@ +//! Podnapisi provider tests. Everything runs against `wiremock` — the live +//! service is never touched (DESIGN.md §15). + +// Same per-target quirk as the crate's own tests: an integration test links +// the library's dependencies without using them all directly. +use { + arr_parse as _, reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _, + zip as _, +}; + +use std::time::Duration; + +use arr_core::Language; +use arr_subs::{ + Candidate, Error, Fetched, MediaFile, MediaRef, PodnapisiProvider, Provider, SearchRequest, + SubtitleFormat, +}; +use wiremock::matchers::{method, path, query_param, query_param_is_missing}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SEARCH_MOVIE_PAGE1: &str = include_str!("fixtures/search_movie_page1.json"); +const SEARCH_MOVIE_PAGE2: &str = include_str!("fixtures/search_movie_page2.json"); +const SEARCH_EPISODE: &str = include_str!("fixtures/search_episode.json"); +const SEARCH_MALFORMED: &str = include_str!("fixtures/search_malformed.json"); +const DOWNLOAD_ZIP: &[u8] = include_bytes!("fixtures/download.zip"); +const DOWNLOAD_MULTI_ZIP: &[u8] = include_bytes!("fixtures/download_multi.zip"); + +fn provider(server: &MockServer) -> PodnapisiProvider { + PodnapisiProvider::builder() + .base_url(format!("{}/subtitles", server.uri())) + .build() + .expect("provider builds") +} + +fn movie_file(release_name: Option<&str>) -> MediaFile { + MediaFile { + path: "/mnt/media/film.mkv".into(), + size: 1_234, + release_name: release_name.map(str::to_owned), + media: MediaRef::Movie { tmdb_id: 42 }, + } +} + +fn request(file: MediaFile, languages: Vec) -> SearchRequest { + SearchRequest { file, languages } +} + +async fn mount_search(server: &MockServer, page: Option<&str>, body: &str) { + let given = Mock::given(method("GET")).and(path("/subtitles/search/advanced")); + let given = match page { + Some(page) => given.and(query_param("page", page)), + None => given.and(query_param_is_missing("page")), + }; + given + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + +#[tokio::test] +async fn a_provider_with_no_release_name_searches_nothing() { + // No mock is mounted: a request would fail the test if one were sent. + let server = MockServer::start().await; + let request = request(movie_file(None), vec![Language::Other("en".to_owned())]); + + let candidates = provider(&server) + .search(&request) + .await + .expect("nothing to search on is not an error"); + + assert!(candidates.is_empty()); +} + +#[tokio::test] +async fn search_paginates_and_dedupes_across_pages() { + let server = MockServer::start().await; + mount_search(&server, None, SEARCH_MOVIE_PAGE1).await; + mount_search(&server, Some("2"), SEARCH_MOVIE_PAGE2).await; + + let request = request( + movie_file(Some("Movie.2024.1080p.WEB-DL-GROUP")), + vec![Language::PortugueseBrazil], + ); + + let candidates = provider(&server) + .search(&request) + .await + .expect("search succeeds"); + + // Three distinct pids across the two pages; "aaa111" repeats on both and + // must survive only once (DESIGN.md's provider forum note on duplicates). + assert_eq!(candidates.len(), 3); + assert!(candidates.iter().all(|candidate| !candidate.hash_match)); +} + +#[tokio::test] +async fn a_candidate_carries_the_release_name_group_and_source() { + let server = MockServer::start().await; + mount_search(&server, None, SEARCH_MOVIE_PAGE1).await; + mount_search(&server, Some("2"), SEARCH_MOVIE_PAGE2).await; + + let request = request( + movie_file(Some("Movie.2024.1080p.WEB-DL-GROUP")), + vec![Language::PortugueseBrazil], + ); + + let candidates = provider(&server) + .search(&request) + .await + .expect("search succeeds"); + + let hearing_impaired = candidates + .iter() + .find(|candidate| candidate.sdh) + .expect("one candidate is flagged hearing_impaired"); + assert_eq!( + hearing_impaired.release_name.as_deref(), + Some("Movie.2024.1080p.WEB-DL-GROUP") + ); + assert_eq!(hearing_impaired.group.as_deref(), Some("GROUP")); + assert_eq!(hearing_impaired.language, Language::PortugueseBrazil); + assert!(!hearing_impaired.forced); + + let plain = candidates + .iter() + .find(|candidate: &&Candidate| { + !candidate.sdh && candidate.language == Language::PortuguesePortugal + }) + .expect("the pt-PT candidate from page two"); + assert_eq!(plain.group.as_deref(), Some("OTHERGRP")); +} + +#[tokio::test] +async fn episode_media_sends_season_and_episode_params() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/search/advanced")) + .and(query_param("seasons", "1")) + .and(query_param("episodes", "2")) + .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_EPISODE)) + .mount(&server) + .await; + + let file = MediaFile { + path: "/mnt/media/show/s01e02.mkv".into(), + size: 999, + release_name: Some("Show.S01E02.1080p.WEB-DL-TEAM".to_owned()), + media: MediaRef::Episode { + tmdb_id: 7, + season: 1, + episode: 2, + }, + }; + let request = request(file, vec![Language::Other("en".to_owned())]); + + let candidates = provider(&server) + .search(&request) + .await + .expect("search succeeds"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].language, Language::Other("en".to_owned())); +} + +#[tokio::test] +async fn a_401_is_unauthorized() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/search/advanced")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let request = request( + movie_file(Some("Movie.2024")), + vec![Language::Other("en".to_owned())], + ); + + let error = provider(&server) + .search(&request) + .await + .expect_err("401 is an error"); + + assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}"); +} + +#[tokio::test] +async fn a_429_surfaces_retry_after() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/search/advanced")) + .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "13")) + .mount(&server) + .await; + + let request = request( + movie_file(Some("Movie.2024")), + vec![Language::Other("en".to_owned())], + ); + + let error = provider(&server) + .search(&request) + .await + .expect_err("429 is an error"); + + match error { + Error::RateLimited { retry_after, .. } => { + assert_eq!(retry_after, Some(Duration::from_secs(13))); + } + other => panic!("expected RateLimited, got {other:?}"), + } +} + +#[tokio::test] +async fn a_response_that_does_not_parse_is_malformed() { + let server = MockServer::start().await; + mount_search(&server, None, SEARCH_MALFORMED).await; + + let request = request( + movie_file(Some("Movie.2024")), + vec![Language::Other("en".to_owned())], + ); + + let error = provider(&server) + .search(&request) + .await + .expect_err("an unparseable body is an error"); + + assert!(matches!(error, Error::Malformed { .. }), "got {error:?}"); +} + +#[tokio::test] +async fn download_extracts_the_single_zipped_file() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/aaa111/download")) + .and(query_param("container", "zip")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(DOWNLOAD_ZIP)) + .mount(&server) + .await; + + let id = arr_subs::CandidateId::new("aaa111|pt-BR"); + let fetched: Fetched = provider(&server) + .download(&id) + .await + .expect("download succeeds"); + + assert_eq!(fetched.id, id); + assert_eq!(fetched.language, Language::PortugueseBrazil); + assert_eq!(fetched.format, SubtitleFormat::Srt); + assert!(fetched.content.starts_with(b"1\n00:00:01")); +} + +#[tokio::test] +async fn a_zip_with_more_than_one_file_is_malformed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/bbb222/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(DOWNLOAD_MULTI_ZIP)) + .mount(&server) + .await; + + let id = arr_subs::CandidateId::new("bbb222|en"); + let error = provider(&server) + .download(&id) + .await + .expect_err("more than one file in the zip is an error"); + + assert!(matches!(error, Error::Malformed { .. }), "got {error:?}"); +} + +#[tokio::test] +async fn an_id_this_provider_never_issued_is_not_found() { + let server = MockServer::start().await; + // No mock mounted: a malformed id must fail before any request is sent. + let id = arr_subs::CandidateId::new("no-language-suffix"); + + let error = provider(&server) + .download(&id) + .await + .expect_err("an id without a language suffix is not this provider's"); + + assert!(matches!(error, Error::NotFound { .. }), "got {error:?}"); +}