// SPDX-License-Identifier: GPL-2.0-only use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer}; use rust_project::{api, config::Config, service::Service, storage::Store}; use serde_json::Value; use std::{net::TcpListener, sync::Arc, time::Duration}; const SEARCH: &str = r#"
"#; // Independently authored fixtures; publish-group ID deliberately differs from RSS group ID. const DETAIL: &str = r#"

测试番剧

放送日期:星期五

Bangumi番组计划链接:详情

字幕组 & A
"#; async fn mock() -> (String, ServerHandle) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let server = HttpServer::new(|| { App::new().default_service(web::to(|req: HttpRequest| async move { if req.path() == "/Home/Bangumi/202" { return HttpResponse::Ok().content_type("text/html").body(format!( "{}
", DETAIL.replace("101", "202"))); } if req.path() == "/images/Bangumi/cover.png" { return HttpResponse::Ok().content_type("image/png").body(b"\x89PNG\r\n\x1a\nfixture".as_slice()); } if req.path() == "/Home/Bangumi/101" { return HttpResponse::Ok().content_type("text/html").body(DETAIL); } if req.path() == "/RSS/Bangumi" { assert_eq!(req.query_string(), "bangumiId=101&subgroupid=12"); return HttpResponse::Ok() .content_type("application/rss+xml") .body(include_str!("fixtures/releases.xml")); } assert_eq!(req.path(), "/Home/Search"); let query = web::Query::>::from_query( req.query_string(), ) .unwrap(); match query.get("searchstr").map(String::as_str) { Some("empty") => return HttpResponse::Ok().content_type("text/html") .body(r#"
"#), Some("challenge") => return HttpResponse::Ok().content_type("text/html").body("

Verify your browser

"), Some("changed") => return HttpResponse::Ok().content_type("text/html") .body(r#"
"#), _ => {} } assert_eq!(query.get("searchstr").unwrap(), "测试 & S2"); HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(SEARCH) })) }) .workers(1) .listen(listener) .unwrap() .run(); let handle = server.handle(); actix_web::rt::spawn(server); (url, handle) } #[actix_web::test] async fn covers_use_an_application_url_and_details_remain_available_in_bounded_cache() { let (base, server) = mock().await; let service = Service::new( Store::open("sqlite::memory:").await.unwrap(), &config(&base), ) .await .unwrap(); let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) .configure(api::configure), ) .await; let detail: Value = test::call_and_read_body_json( &app, test::TestRequest::get() .uri("/api/sources/mikan/series/202") .to_request(), ) .await; assert_eq!(detail["cover_url"], "/api/sources/mikan/series/202/cover"); let response = test::call_service( &app, test::TestRequest::get() .uri(detail["cover_url"].as_str().unwrap()) .to_request(), ) .await; assert_eq!(response.status(), 200); assert_eq!(response.headers().get("Content-Type").unwrap(), "image/png"); assert_eq!( test::read_body(response).await.as_ref(), b"\x89PNG\r\n\x1a\nfixture" ); server.stop(false).await; let cached: Value = test::call_and_read_body_json( &app, test::TestRequest::get() .uri("/api/sources/mikan/series/202") .to_request(), ) .await; assert_eq!(cached, detail); } #[actix_web::test] async fn empty_results_are_distinct_from_challenges_or_changed_markup() { let (base, server) = mock().await; let service = Service::new( Store::open("sqlite::memory:").await.unwrap(), &config(&base), ) .await .unwrap(); let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) .configure(api::configure), ) .await; for (query, status) in [ ("empty", 200), ("challenge", 502), ("changed", 502), ("%20", 400), ] { let response = test::call_service( &app, test::TestRequest::get() .uri(&format!("/api/sources/mikan/search?q={query}")) .to_request(), ) .await; assert_eq!(response.status(), status, "query {query}"); if status == 200 { let value: Value = test::read_body_json(response).await; assert_eq!(value["series"], serde_json::json!([])); } } server.stop(false).await; } #[actix_web::test] async fn group_candidate_can_be_previewed_and_saved_disabled_without_downloading() { let (base, server) = mock().await; let service = Service::new( Store::open("sqlite::memory:").await.unwrap(), &config(&base), ) .await .unwrap(); let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) .configure(api::configure), ) .await; let response = test::call_service( &app, test::TestRequest::get() .uri("/api/sources/mikan/series/101") .to_request(), ) .await; assert_eq!(response.status(), 200); let detail: Value = test::read_body_json(response).await; assert_eq!(detail["title"], "测试番剧"); assert_eq!(detail["bangumi_id"], "400602"); assert_eq!(detail["groups"][0]["group_id"], "12"); assert_eq!(detail["groups"][0]["name"], "字幕组 & A"); assert!(detail["cover_url"].is_null()); let response = test::call_service( &app, test::TestRequest::post() .uri("/api/sources/mikan/resolve") .set_json(serde_json::json!({"url": detail["groups"][0]["rss_url"]})) .to_request(), ) .await; assert_eq!(response.status(), 200); let candidate: Value = test::read_body_json(response).await; assert_eq!(candidate["scope"], "group"); assert_eq!(candidate["series_id"], "101"); assert_eq!(candidate["group_id"], "12"); assert_eq!(candidate["subscription"]["enabled"], false); let preview: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri("/api/preview") .set_json(&candidate["subscription"]) .to_request(), ) .await; assert_eq!(preview[0]["episode"], 3); let saved = test::call_service( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(&candidate["subscription"]) .to_request(), ) .await; assert_eq!(saved.status(), 201); let jobs: Value = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; assert_eq!(jobs, serde_json::json!([])); server.stop(true).await; } fn config(base: &str) -> Config { Config { database_url: "sqlite::memory:".into(), port: 8080, poll_interval: Duration::from_secs(300), qbit: None, mikan_base_url: base.into(), website: Default::default(), } } #[actix_web::test] async fn resolver_marks_keyword_scope_and_rejects_foreign_or_ambiguous_urls() { let (base, server) = mock().await; let service = Service::new( Store::open("sqlite::memory:").await.unwrap(), &config(&base), ) .await .unwrap(); let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) .configure(api::configure), ) .await; let response = test::call_service( &app, test::TestRequest::post() .uri("/api/sources/mikan/resolve") .set_json(serde_json::json!({"url": format!("{base}/RSS/Search?searchstr=test")})) .to_request(), ) .await; assert_eq!(response.status(), 200); let candidate: Value = test::read_body_json(response).await; assert_eq!(candidate["scope"], "keyword"); assert!(candidate["series_id"].is_null()); assert!(candidate["subscription"].is_null()); assert!(!candidate["warnings"].as_array().unwrap().is_empty()); for url in [ "https://other.invalid/Home/Bangumi/101".into(), format!("{base}/RSS/Bangumi?bangumiId=101&bangumiId=202"), format!("{base}/RSS/Bangumi?bangumiId=101&subgroupid=999"), format!("{base}/Home/Bangumi/not-an-id"), ] { let response = test::call_service( &app, test::TestRequest::post() .uri("/api/sources/mikan/resolve") .set_json(serde_json::json!({"url": url})) .to_request(), ) .await; assert_eq!(response.status(), 400); } server.stop(false).await; } #[actix_web::test] async fn search_discovers_distinct_series_and_encoded_keyword_rss_without_creating_jobs() { let (base, server) = mock().await; let service = Service::new( Store::open("sqlite::memory:").await.unwrap(), &config(&base), ) .await .unwrap(); let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) .configure(api::configure), ) .await; let response = test::call_service( &app, test::TestRequest::get() .uri("/api/sources/mikan/search?q=%E6%B5%8B%E8%AF%95%20%26%20S2") .to_request(), ) .await; assert_eq!(response.status(), 200); let body: Value = test::read_body_json(response).await; assert_eq!(body["series"][0]["series_id"], "101"); assert_eq!(body["series"][1]["title"], "测试番剧 第二季"); let rss = reqwest::Url::parse(body["keyword_rss_url"].as_str().unwrap()).unwrap(); assert_eq!(rss.path(), "/RSS/Search"); assert_eq!( rss.query_pairs().collect::>(), vec![("searchstr".into(), "测试 & S2".into())] ); let jobs: Value = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; assert_eq!(jobs, serde_json::json!([])); server.stop(true).await; }