b9f4ee98d5
The Podnapisi zip fixtures never reached the branch: a global gitignore excludes *.zip, so git add skipped them silently and the worker's CI passed against untracked files. Recovered, with a fixtures .gitignore that keeps the next binary fixture from vanishing the same way. Two providers landing in parallel also disagreed on names and on which crate dependencies each integration-test target uses. PodnapisiProvider is now Podnapisi, matching OpenSubtitles, and both test targets declare the dependencies they do not use so the per-target lint stays quiet.
285 lines
9.1 KiB
Rust
285 lines
9.1 KiB
Rust
//! 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 _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, serde_json as _,
|
|
tempfile as _, thiserror as _, tracing as _, zip as _,
|
|
};
|
|
|
|
use std::time::Duration;
|
|
|
|
use arr_core::Language;
|
|
use arr_subs::{
|
|
Candidate, Error, Fetched, MediaFile, MediaRef, Podnapisi, 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) -> Podnapisi {
|
|
Podnapisi::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<Language>) -> 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:?}");
|
|
}
|