//! 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:?}"); }