diff --git a/crates/arr-subs/src/command.rs b/crates/arr-subs/src/command.rs index 465fc7a..b20a333 100644 --- a/crates/arr-subs/src/command.rs +++ b/crates/arr-subs/src/command.rs @@ -35,8 +35,8 @@ use tokio::{ use arr_core::Language; use crate::translate::{ - strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture, - TranslatedCue, + strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result, + TranslateFuture, TranslatedCue, }; const BACKEND_NAME: &str = "command"; @@ -210,6 +210,35 @@ impl Backend for Command { fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> { Box::pin(async move { self.translate_inner(batch).await }) } + + /// Runs the template once over an empty batch (#200): the lamp is the + /// command starting and exiting cleanly, not what it says. The same + /// timeout cell bounds it, so a wedged template cannot pile up probes. + fn probe(&self) -> ProbeFuture<'_> { + Box::pin(async move { + let payload = format!( + "{}\n[]\n", + system_prompt(&Language::Other("en".to_owned()), &Language::PortuguesePortugal) + ); + let mut tokens = argv(&self.config.template); + for token in &mut tokens { + *token = token + .replace("{source}", language_tag(&Language::Other("en".to_owned()))) + .replace("{target}", language_tag(&Language::PortuguesePortugal)); + } + let (program, args) = tokens.split_first().expect("the constructor rejects empty"); + + let timeout = Duration::from_millis(self.timeout_ms.load(Ordering::Relaxed)); + match time::timeout(timeout, run(program, args, payload, self.id.clone())).await { + Err(_) => Err(Error::Transport { + backend: self.id(), + source: format!("timed out after {timeout:?}").into(), + }), + Ok(Err(err)) => Err(err), + Ok(Ok(_)) => Ok(()), + } + }) + } } /// Spawn the command once, feed it `payload`, and collect its stdout. diff --git a/crates/arr-subs/src/deepl.rs b/crates/arr-subs/src/deepl.rs index e18c81f..f4c7217 100644 --- a/crates/arr-subs/src/deepl.rs +++ b/crates/arr-subs/src/deepl.rs @@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize}; use arr_core::Language; -use crate::translate::{Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue}; +use crate::translate::{ + Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue, +}; use crate::{Error as SubsError, ProviderId, Result}; /// The `DeepL` API endpoint for keys ending in `:fx` (the free tier). @@ -282,6 +284,31 @@ impl Backend for DeepL { .collect()) }) } + + /// `/v2/usage` is the documented key check: one cheap authenticated GET + /// that spends none of the character quota (#200). + fn probe(&self) -> ProbeFuture<'_> { + Box::pin(async move { + let reply = self + .http + .get(self.join("usage")) + .header("DeepL-Auth-Key", &self.config.auth_key) + .send() + .await + .map_err(|error| Error::Transport { + backend: self.id(), + source: Box::new(error), + })?; + match reply.status() { + StatusCode::OK => Ok(()), + StatusCode::FORBIDDEN => Err(Error::Unauthorized { backend: self.id() }), + status => Err(Error::Malformed { + backend: self.id(), + detail: format!("status {}", status.as_u16()), + }), + } + }) + } } fn retry_after(headers: &reqwest::header::HeaderMap) -> Option { @@ -534,4 +561,31 @@ mod tests { }; assert_eq!(retry_after, Some(Duration::from_secs(30))); } + + #[tokio::test] + async fn a_probe_asks_usage_and_judges_the_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v2/usage")) + .and(header("DeepL-Auth-Key", KEY)) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + deepl(format!("{}/v2", server.uri())) + .probe() + .await + .expect("a 200 usage answer is a lit lamp"); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/v2/usage")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + let error = deepl(format!("{}/v2", server.uri())) + .probe() + .await + .expect_err("bad key"); + assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}"); + } } diff --git a/crates/arr-subs/src/google.rs b/crates/arr-subs/src/google.rs index 244e1dc..55f4d25 100644 --- a/crates/arr-subs/src/google.rs +++ b/crates/arr-subs/src/google.rs @@ -26,7 +26,9 @@ use serde::{Deserialize, Serialize}; use arr_core::Language; -use crate::translate::{Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue}; +use crate::translate::{ + Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue, +}; use crate::{Error as SubsError, ProviderId, Result}; /// The public Google Cloud Translation v2 endpoint. @@ -291,6 +293,39 @@ impl Backend for Google { .collect()) }) } + + /// `languages` is the cheapest authenticated v2 call and spends none of + /// the translation quota (#200). The request itself is fixed, so a 400 + /// can only mean the key was refused — Google reports bad keys as 400, + /// not 401. + fn probe(&self) -> ProbeFuture<'_> { + Box::pin(async move { + let mut url = self.base_url.clone(); + url.set_path(&format!("{}/languages", url.path().trim_end_matches('/'))); + url.query_pairs_mut() + .append_pair("key", &self.config.api_key); + + let reply = self + .http + .get(url) + .send() + .await + .map_err(|error| Error::Transport { + backend: self.id(), + source: Box::new(error), + })?; + match reply.status() { + StatusCode::OK => Ok(()), + StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => { + Err(Error::Unauthorized { backend: self.id() }) + } + status => Err(Error::Malformed { + backend: self.id(), + detail: format!("status {}", status.as_u16()), + }), + } + }) + } } fn api_error_detail(status: StatusCode, body: &[u8]) -> String { @@ -367,7 +402,7 @@ fn numeric_entity(entity: &str) -> Option { #[cfg(test)] mod tests { use arr_core::Language; - use wiremock::matchers::method; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::{Google, GoogleConfig}; @@ -584,4 +619,31 @@ mod tests { // A stray ampersand that is not an entity stays one. assert_eq!(unescape_html("fish & chips"), "fish & chips"); } + + #[tokio::test] + async fn a_probe_lists_languages_and_judges_the_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v2/languages")) + .and(query_param("key", KEY)) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + google(format!("{}/v2", server.uri())) + .probe() + .await + .expect("a languages answer is a lit lamp"); + + server.reset().await; + // Google reports a bad key as a 400, not a 401. + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(400)) + .mount(&server) + .await; + let error = google(format!("{}/v2", server.uri())) + .probe() + .await + .expect_err("bad key"); + assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}"); + } } diff --git a/crates/arr-subs/src/lib.rs b/crates/arr-subs/src/lib.rs index b68c4e0..7e3f855 100644 --- a/crates/arr-subs/src/lib.rs +++ b/crates/arr-subs/src/lib.rs @@ -55,7 +55,8 @@ pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig}; pub use podnapisi::{Podnapisi, PodnapisiBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL}; pub use srt::Cue; pub use sync::{ - Outcome, Rejection, Settled, SyncState, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY, + binary_present, Outcome, Rejection, Settled, SyncState, Syncer, + DEFAULT_BINARY as ALASS_DEFAULT_BINARY, }; pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue}; @@ -65,6 +66,9 @@ pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, Transl #[cfg(test)] use wiremock as _; +/// One health probe (#200): reachability and, where they exist, credentials. +pub type ProbeFuture<'a> = Pin> + Send + 'a>>; + /// The candidates one search turned up. pub type SearchFuture<'a> = Pin>> + Send + 'a>>; @@ -131,6 +135,14 @@ pub trait Provider: fmt::Debug + Send + Sync { /// Anything in [`Error`], and [`Error::NotFound`] when the candidate has /// gone away between the search and the download. fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a>; + + /// One health probe (#200): reachable, and credentials accepted. + /// + /// Cheap by contract — the endpoint carrying it is polled. It must cost + /// no search or download quota: a probe that spends budget is a lamp + /// that drains the account it watches. [`Error::Unauthorized`] means the + /// credentials were refused; anything else is an outage. + fn probe(&self) -> ProbeFuture<'_>; } #[cfg(test)] @@ -190,6 +202,10 @@ mod tests { }) }) } + + fn probe(&self) -> super::ProbeFuture<'_> { + Box::pin(async move { Ok(()) }) + } } fn request() -> SearchRequest { diff --git a/crates/arr-subs/src/openai.rs b/crates/arr-subs/src/openai.rs index cc5b17c..5453997 100644 --- a/crates/arr-subs/src/openai.rs +++ b/crates/arr-subs/src/openai.rs @@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize}; use arr_core::Language; use crate::translate::{ - strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture, - TranslatedCue, + strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result, + TranslateFuture, TranslatedCue, }; const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/"; @@ -262,6 +262,36 @@ impl Backend for OpenAi { fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> { Box::pin(async move { self.translate_inner(batch).await }) } + + /// `models` is a read-only listing: reachable and key accepted, nothing + /// spent (#200). Endpoints that need no key are probed the same way, + /// just without the header. + fn probe(&self) -> ProbeFuture<'_> { + Box::pin(async move { + let mut url = self.base_url.clone(); + url.set_path(&format!("{}/models", url.path().trim_end_matches('/'))); + let mut request = self.http.get(url); + if let Some(key) = &self.config.api_key { + request = request.bearer_auth(key); + } + match request.send().await { + Ok(response) => match response.status() { + StatusCode::OK => Ok(()), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(Error::Unauthorized { backend: self.id() }) + } + status => Err(Error::Malformed { + backend: self.id(), + detail: format!("status {}", status.as_u16()), + }), + }, + Err(error) => Err(Error::Transport { + backend: self.id(), + source: Box::new(error), + }), + } + }) + } } fn retry_after(response: &reqwest::Response) -> Option { @@ -382,4 +412,50 @@ mod tests { assert_eq!(strip_code_fence("```\n[1,2]\n```"), "[1,2]"); assert_eq!(strip_code_fence("[1,2]"), "[1,2]"); } + + #[tokio::test] + async fn a_probe_lists_models_and_judges_the_key() { + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/models")) + .and(header("Authorization", "Bearer test-key")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + OpenAi::with_base_url( + "gpt-4o-mini", + super::OpenAiConfig { + api_key: Some("test-key".to_owned()), + }, + &format!("{}/v1", server.uri()), + ) + .expect("client builds") + .probe() + .await + .expect("a models answer is a lit lamp"); + + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let error = OpenAi::with_base_url( + "gpt-4o-mini", + super::OpenAiConfig { + api_key: Some("test-key".to_owned()), + }, + &format!("{}/v1", server.uri()), + ) + .expect("client builds") + .probe() + .await + .expect_err("bad key"); + assert!( + matches!(error, crate::translate::Error::Unauthorized { .. }), + "got {error:?}" + ); + } } diff --git a/crates/arr-subs/src/opensubtitles.rs b/crates/arr-subs/src/opensubtitles.rs index a456111..d4b601b 100644 --- a/crates/arr-subs/src/opensubtitles.rs +++ b/crates/arr-subs/src/opensubtitles.rs @@ -222,6 +222,23 @@ impl OpenSubtitles { query: Option<&[(String, String)]>, json: Option<&serde_json::Value>, bearer: Option<&str>, + ) -> Result { + let response = self.send_raw(method, path, query, json, bearer).await?; + self.check_status(response).await + } + + /// Send a request and hand back the raw response, whatever its status. + /// + /// The health probe (#200) needs statuses `check_status` folds away: a + /// 400 there means "the API refused the request *after* accepting the + /// key", which is exactly what a lamp wants to hear. + async fn send_raw( + &self, + method: Method, + path: &str, + query: Option<&[(String, String)]>, + json: Option<&serde_json::Value>, + bearer: Option<&str>, ) -> Result { let url = self.base_url.join(path).map_err(|err| Error::Malformed { provider: self.id.clone(), @@ -244,11 +261,10 @@ impl OpenSubtitles { } tracing::debug!(provider = %self.id, path, "OpenSubtitles request"); - let response = request.send().await.map_err(|err| Error::Transport { + request.send().await.map_err(|err| Error::Transport { provider: self.id.clone(), source: Box::new(err), - })?; - self.check_status(response).await + }) } /// Fetch (or refetch) the user token downloads travel under. @@ -292,6 +308,34 @@ impl OpenSubtitles { self.login().await } + /// One health probe (#200): reachable, and the API key accepted. + /// + /// Deliberately not a real search — searches spend quota, and health is + /// polled. An unparseable candidate id is refused with a 4xx *after* + /// authentication, so anything short of an auth refusal or a 5xx is + /// proof the key was accepted, at no cost. + async fn probe_inner(&self) -> Result<()> { + let response = self + .send_raw( + Method::GET, + "subtitles", + Some(&[("id".to_owned(), "not-a-number".to_owned())]), + None, + None, + ) + .await?; + match response.status() { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Unauthorized { + provider: self.id.clone(), + }), + status if status.is_server_error() => Err(Error::Malformed { + provider: self.id.clone(), + detail: format!("status {}", status.as_u16()), + }), + _ => Ok(()), + } + } + async fn search_inner(&self, request: &SearchRequest) -> Result> { let hash = moviehash(&request.file.path, request.file.size).map_err(|source| Error::Io { @@ -432,6 +476,10 @@ impl Provider for OpenSubtitles { fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> { Box::pin(async move { self.download_inner(id).await }) } + + fn probe(&self) -> crate::ProbeFuture<'_> { + Box::pin(async move { self.probe_inner().await }) + } } fn retry_after(response: &reqwest::Response) -> Option { diff --git a/crates/arr-subs/src/podnapisi.rs b/crates/arr-subs/src/podnapisi.rs index d6206d7..78e3bba 100644 --- a/crates/arr-subs/src/podnapisi.rs +++ b/crates/arr-subs/src/podnapisi.rs @@ -255,6 +255,14 @@ impl Provider for Podnapisi { fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> { Box::pin(async move { self.download_candidate(id).await }) } + + /// Anonymous, so the lamp is reachability alone (#200): the site root + /// answers, no quota spent. + fn probe(&self) -> crate::ProbeFuture<'_> { + Box::pin(async move { + self.send(self.base_url.clone(), None).await.map(|_| ()) + }) + } } /// Configuration for a [`Podnapisi`]. diff --git a/crates/arr-subs/src/sync.rs b/crates/arr-subs/src/sync.rs index e47b575..4c2d4bf 100644 --- a/crates/arr-subs/src/sync.rs +++ b/crates/arr-subs/src/sync.rs @@ -14,7 +14,7 @@ //! reference is always the media file itself; subtitle and video are both on //! disk by the time this runs. -use std::{ffi::OsString, path::Path, process::Stdio, time::Duration}; +use std::{ffi::OsStr, ffi::OsString, path::Path, process::Stdio, time::Duration}; use tokio::process::Command; @@ -37,6 +37,38 @@ pub const MAX_SHIFT: Duration = Duration::from_secs(60); /// How much of `alass`'s stderr is kept in an error. const STDERR_LIMIT: usize = 512; +/// Whether a configured external binary resolves to an executable. +/// +/// A name with any path component (`/usr/local/bin/alass`, `./alass`) must +/// exist and be executable exactly where it points; a bare name (`alass`) is +/// resolved through `PATH`, the way spawning it would (#200). No process is +/// started: the health endpoint is polled, and launching the real thing per +/// poll would be neither cheap nor side-effect-free. +#[must_use] +pub fn binary_present(binary: &OsStr) -> bool { + let path = Path::new(binary); + if path.components().count() > 1 { + return is_executable_file(path); + } + std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join(path)) + .any(|candidate| is_executable_file(&candidate)) + }) +} + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.is_file() + && std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &Path) -> bool { + path.is_file() +} + /// Runs `alass` over one subtitle against its video. #[derive(Clone, Debug)] pub struct Syncer { @@ -74,6 +106,18 @@ impl Syncer { self } + /// The configured binary, as health probes (#200) report it. + #[must_use] + pub fn binary_path(&self) -> &OsStr { + &self.binary + } + + /// Whether [`Self::binary_path`] resolves to an executable (#200). + #[must_use] + pub fn present(&self) -> bool { + binary_present(&self.binary) + } + /// Sync `subtitle` against `video`, in place on disk. /// /// Returns the synced SRT text on acceptance; the input file is never @@ -637,4 +681,31 @@ mod tests { "1 of 2 cues survived" ); } + + /// The health lamp (#200) is made of this, so it must agree with what + /// spawning would do: an explicit path is judged where it points, a bare + /// name through `PATH`, and a missing or non-executable file is absent. + #[test] + fn binary_presence_follows_the_same_rules_spawning_does() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let executable = dir.path().join("alass"); + std::fs::write(&executable, "#!/bin/sh\n").expect("write"); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)) + .expect("chmod"); + let plain = dir.path().join("not-executable"); + std::fs::write(&plain, "text").expect("write"); + + assert!(super::binary_present(executable.as_os_str())); + assert!(!super::binary_present(plain.as_os_str())); + assert!(!super::binary_present(std::ffi::OsStr::new( + "/nowhere/alass" + ))); + // `sh` is on PATH of every machine that runs these tests. + assert!(super::binary_present(std::ffi::OsStr::new("sh"))); + assert!(Syncer::new() + .with_binary(executable.as_os_str()) + .present()); + } } diff --git a/crates/arr-subs/src/translate.rs b/crates/arr-subs/src/translate.rs index 7b9423b..2c1efe7 100644 --- a/crates/arr-subs/src/translate.rs +++ b/crates/arr-subs/src/translate.rs @@ -105,6 +105,9 @@ pub struct TranslatedCue { pub type TranslateFuture<'a> = Pin>> + Send + 'a>>; +/// One health probe (#200): reachable and credentials accepted. +pub type ProbeFuture<'a> = Pin> + Send + 'a>>; + /// One translation backend (DESIGN.md §15). /// /// Boxed futures rather than `async fn` for the same reason as @@ -146,6 +149,15 @@ pub trait Backend: fmt::Debug + Send + Sync { /// /// Anything in [`Error`]. fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a>; + + /// One health probe (#200): reachable, and credentials accepted. + /// + /// For the remote-command backend this means the configured command + /// runs and exits cleanly. Cheap by contract — the endpoint carrying it + /// is polled, and it must spend none of the quota translation spends. + /// [`Error::Unauthorized`] means the credentials were refused; anything + /// else is an outage. + fn probe(&self) -> ProbeFuture<'_>; } /// Result alias for translation. @@ -424,7 +436,9 @@ mod tests { use arr_core::Language; - use super::{translate, Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue}; + use super::{ + translate, Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue, + }; use crate::srt::Cue; /// Uppercases every cue and records how it was called; misbehaves on cue. @@ -466,6 +480,10 @@ mod tests { Ok(reply) }) } + + fn probe(&self) -> ProbeFuture<'_> { + Box::pin(async move { Ok(()) }) + } } fn cues(texts: &[&str]) -> Vec { diff --git a/crates/arr-subs/tests/command.rs b/crates/arr-subs/tests/command.rs index 49ef69b..15dcde2 100644 --- a/crates/arr-subs/tests/command.rs +++ b/crates/arr-subs/tests/command.rs @@ -21,7 +21,7 @@ use std::{ use arr_core::Language; use arr_subs::translate::{translate, Error}; -use arr_subs::{Command, CommandConfig}; +use arr_subs::{Backend as _, Command, CommandConfig}; fn cues(texts: &[&str]) -> Vec { texts @@ -270,3 +270,25 @@ async fn an_edited_timeout_reaches_the_next_call() { } const DEFAULT: Duration = arr_subs::COMMAND_DEFAULT_TIMEOUT; + +/// The lamp (#200) is the command starting and exiting cleanly — not what it +/// says. An empty batch goes in; only the exit status is judged. +#[tokio::test] +async fn a_probe_runs_the_template_and_wants_a_clean_exit() { + let dir = tempfile::tempdir().expect("tempdir"); + + let ok = stub(dir.path(), "ok.sh", "cat > /dev/null\n"); + Command::new(config(&ok.display().to_string(), DEFAULT)) + .expect("backend constructs") + .probe() + .await + .expect("a clean exit is a lit lamp"); + + let failing = stub(dir.path(), "fail.sh", "echo nope >&2\nexit 3\n"); + let error = Command::new(config(&failing.display().to_string(), DEFAULT)) + .expect("backend constructs") + .probe() + .await + .expect_err("non-zero exit"); + assert!(matches!(error, Error::Transport { .. }), "got {error:?}"); +} diff --git a/crates/arr-subs/tests/opensubtitles.rs b/crates/arr-subs/tests/opensubtitles.rs index 6e53a89..babb568 100644 --- a/crates/arr-subs/tests/opensubtitles.rs +++ b/crates/arr-subs/tests/opensubtitles.rs @@ -424,3 +424,42 @@ async fn an_id_this_provider_never_offered_is_not_found() { other => panic!("expected NotFound, got {other:?}"), } } + +#[tokio::test] +async fn a_probe_passes_when_the_key_is_accepted_even_on_a_refused_request() { + let server = MockServer::start().await; + // An unparseable candidate id is rejected *after* authentication: any + // 4xx short of 401/403 is proof the key was accepted, and spends no + // search quota (#200). + Mock::given(method("GET")) + .and(path("/api/v1/subtitles")) + .respond_with(ResponseTemplate::new(400)) + .mount(&server) + .await; + + client(&server) + .probe() + .await + .expect("a refused request still proves the key"); +} + +#[tokio::test] +async fn a_probe_reports_refused_credentials_and_outages() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/subtitles")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let err = client(&server).probe().await.expect_err("bad key"); + assert!(matches!(err, Error::Unauthorized { .. })); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/api/v1/subtitles")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let err = client(&server).probe().await.expect_err("outage"); + assert!(matches!(err, Error::Malformed { .. })); +} diff --git a/crates/arr-subs/tests/podnapisi.rs b/crates/arr-subs/tests/podnapisi.rs index dc11213..e684957 100644 --- a/crates/arr-subs/tests/podnapisi.rs +++ b/crates/arr-subs/tests/podnapisi.rs @@ -282,3 +282,26 @@ async fn an_id_this_provider_never_issued_is_not_found() { assert!(matches!(error, Error::NotFound { .. }), "got {error:?}"); } + +#[tokio::test] +async fn a_probe_is_reachability_alone() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/subtitles/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + provider(&server) + .probe() + .await + .expect("the anonymous provider is up when the site answers"); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/subtitles/")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let error = provider(&server).probe().await.expect_err("site down"); + assert!(matches!(error, Error::Malformed { .. }), "got {error:?}"); +}