Files
tuliprox/backend/curation/src/test_support.rs
T
Juanjo PresaandGitHub 0d4f113c54 feat: add TMDB Trending target curation (#872)
- add target-wide TMDB Trending discovery for movie/TV and day/week, bounded by an optional `limit` (default 100, range 1–500) of unique remote references before local matching
- compose concrete Trakt and TMDB sources under one target curation policy while retaining legacy `output[].trakt` compatibility
- match only positive TMDB IDs of the same media kind and preserve existing catalog identities, series closure, and Xtream alias behavior
- require every active selector to complete before target IDs, writers, caches, or watches can change
- preserve the YAML-owned block through Source Editor operations and add TMDB setup/failure documentation and application Credits attribution
- validate complete decoded pages under independent request, selector, and shared target-batch budgets, using a configured TMDB-only HTTP profile with certificate/hostname verification and no redirects or replays
2026-09-24 17:36:34 +02:00

119 lines
4.6 KiB
Rust

use std::{
future::{poll_fn, Future},
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::mpsc,
task::JoinHandle,
};
pub(crate) fn http_response(status: u16, body: &str) -> String {
format!("HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
}
pub(crate) struct TestServer {
pub(crate) url: String,
pub(crate) requests: Arc<Mutex<Vec<String>>>,
task: JoinHandle<()>,
}
impl TestServer {
pub(crate) async fn new(response: String) -> Self { Self::new_bytes(response.into_bytes()).await }
pub(crate) async fn new_bytes(response: Vec<u8>) -> Self {
Self::delayed_bytes(response, Duration::ZERO, false).await
}
pub(crate) async fn delayed_bytes(response: Vec<u8>, delay: Duration, send_headers: bool) -> Self {
Self::serve(vec![response], delay, send_headers).await
}
pub(crate) async fn sequence(responses: Vec<String>) -> Self {
Self::serve(responses.into_iter().map(String::into_bytes).collect(), Duration::ZERO, false).await
}
pub(crate) async fn serve(responses: Vec<Vec<u8>>, delay: Duration, send_headers: bool) -> Self {
assert!(!responses.is_empty());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&requests);
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let request = read_request(&mut stream).await;
let index = {
let mut requests = recorded.lock().unwrap();
requests.push(request);
requests.len() - 1
};
let response = &responses[index.min(responses.len() - 1)];
let split = if send_headers {
response.windows(4).position(|bytes| bytes == b"\r\n\r\n").unwrap() + 4
} else {
0
};
let _ = stream.write_all(&response[..split]).await;
tokio::time::sleep(delay).await;
let _ = stream.write_all(&response[split..]).await;
}
});
Self { url, requests, task }
}
/// Each received stream is a request-observed milestone. The test owns when
/// headers/body/chunks are released, and keeps stalled streams open until timeout.
pub(crate) async fn controlled() -> (Self, mpsc::UnboundedReceiver<TcpStream>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&requests);
let (send, receive) = mpsc::unbounded_channel();
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let request = read_request(&mut stream).await;
recorded.lock().unwrap().push(request);
if send.send(stream).is_err() {
break;
}
}
});
(Self { url, requests, task }, receive)
}
}
async fn read_request(stream: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
assert!(count > 0, "connection closed before request headers");
request.extend_from_slice(&buffer[..count]);
assert!(request.len() <= 16384);
}
String::from_utf8(request).unwrap()
}
/// Keep the paused runtime ready while real I/O progresses, preventing automatic
/// clock advance. This real-time watchdog also bounds missing milestones. All
/// acquisition/controller futures are inline, so panic drops them, not detached tasks.
pub(crate) async fn with_paused_io<T>(future: impl Future<Output = T>) -> T {
let started = std::time::Instant::now();
let mut future = std::pin::pin!(future);
poll_fn(|cx| {
assert!(started.elapsed() < Duration::from_secs(5), "paused-I/O test stalled (real-time watchdog)");
let result = future.as_mut().poll(cx);
if result.is_pending() {
cx.waker().wake_by_ref();
}
result
})
.await
}
impl Drop for TestServer {
fn drop(&mut self) { self.task.abort(); }
}