fix: fix testsuite
This commit is contained in:
@@ -105,7 +105,7 @@ pub enum RecorderError {
|
||||
ModelEntityNotFound { entity: Cow<'static, str> },
|
||||
#[snafu(transparent)]
|
||||
FetchError { source: FetchError },
|
||||
#[snafu(display("Credential3rdError: {source}"))]
|
||||
#[snafu(display("Credential3rdError: {message}, source = {source}"))]
|
||||
Credential3rdError {
|
||||
message: String,
|
||||
#[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))]
|
||||
@@ -113,6 +113,8 @@ pub enum RecorderError {
|
||||
},
|
||||
#[snafu(transparent)]
|
||||
CryptoError { source: CryptoError },
|
||||
#[snafu(transparent)]
|
||||
StringFromUtf8Error { source: std::string::FromUtf8Error },
|
||||
#[snafu(display("{message}"))]
|
||||
Whatever {
|
||||
message: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::{fmt::Debug, ops::Deref, sync::Arc};
|
||||
|
||||
use fetch::{HttpClient, HttpClientTrait};
|
||||
use maplit::hashmap;
|
||||
use scraper::{Html, Selector};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DbErr, EntityTrait, QueryFilter, TryIntoModel,
|
||||
};
|
||||
@@ -68,50 +69,44 @@ impl MikanClient {
|
||||
message: "mikan login failed, credential required".to_string(),
|
||||
source: None.into(),
|
||||
})?;
|
||||
|
||||
let login_page_url = {
|
||||
let mut u = self.base_url.join(MIKAN_LOGIN_PAGE_PATH)?;
|
||||
u.set_query(Some(MIKAN_LOGIN_PAGE_SEARCH));
|
||||
u
|
||||
};
|
||||
|
||||
// access login page to get antiforgery cookie
|
||||
self.http_client
|
||||
.get(login_page_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| RecorderError::Credential3rdError {
|
||||
message: "failed to get mikan login page".to_string(),
|
||||
source: OptDynErr::some_boxed(error),
|
||||
})?;
|
||||
let antiforgery_token = {
|
||||
// access login page to get antiforgery cookie
|
||||
let login_page_html = self
|
||||
.http_client
|
||||
.get(login_page_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| RecorderError::Credential3rdError {
|
||||
message: "failed to get mikan login page".to_string(),
|
||||
source: OptDynErr::some_boxed(error),
|
||||
})?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let antiforgery_cookie = {
|
||||
let cookie_store_lock = self.http_client.cookie_store.clone().ok_or_else(|| {
|
||||
RecorderError::Credential3rdError {
|
||||
message: "failed to get cookie store".to_string(),
|
||||
let login_page_html = Html::parse_document(&login_page_html);
|
||||
|
||||
let antiforgery_selector =
|
||||
Selector::parse("input[name='__RequestVerificationToken']").unwrap();
|
||||
|
||||
login_page_html
|
||||
.select(&antiforgery_selector)
|
||||
.next()
|
||||
.and_then(|element| element.value().attr("value").map(|value| value.to_string()))
|
||||
.ok_or_else(|| RecorderError::Credential3rdError {
|
||||
message: "mikan login failed, failed to get antiforgery token".to_string(),
|
||||
source: None.into(),
|
||||
}
|
||||
})?;
|
||||
let cookie_store =
|
||||
cookie_store_lock
|
||||
.read()
|
||||
.map_err(|_| RecorderError::Credential3rdError {
|
||||
message: "failed to read cookie store".to_string(),
|
||||
source: None.into(),
|
||||
})?;
|
||||
|
||||
cookie_store
|
||||
.matches(&login_page_url)
|
||||
.iter()
|
||||
.find(|cookie| cookie.name().starts_with(".AspNetCore.Antiforgery."))
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
}
|
||||
.ok_or_else(|| RecorderError::Credential3rdError {
|
||||
message: "mikan login failed, failed to get antiforgery cookie".to_string(),
|
||||
source: None.into(),
|
||||
})?;
|
||||
})
|
||||
}?;
|
||||
|
||||
let login_post_form = hashmap! {
|
||||
"__RequestVerificationToken".to_string() => antiforgery_cookie,
|
||||
"__RequestVerificationToken".to_string() => antiforgery_token,
|
||||
"UserName".to_string() => userpass_credential.username.clone(),
|
||||
"Password".to_string() => userpass_credential.password.clone(),
|
||||
"RememberMe".to_string() => "true".to_string(),
|
||||
@@ -185,12 +180,33 @@ impl MikanClient {
|
||||
}
|
||||
|
||||
pub async fn fork_with_credential(
|
||||
&self,
|
||||
userpass_credential: UserPassCredential,
|
||||
) -> RecorderResult<Self> {
|
||||
let mut fork = self
|
||||
.http_client
|
||||
.fork()
|
||||
.attach_cookies(userpass_credential.cookies.as_deref())?;
|
||||
|
||||
if let Some(user_agent) = userpass_credential.user_agent.as_ref() {
|
||||
fork = fork.attach_user_agent(user_agent);
|
||||
}
|
||||
|
||||
let userpass_credential_opt = Some(userpass_credential);
|
||||
|
||||
Ok(Self {
|
||||
http_client: HttpClient::from_fork(fork)?,
|
||||
base_url: self.base_url.clone(),
|
||||
origin_url: self.origin_url.clone(),
|
||||
userpass_credential: userpass_credential_opt,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fork_with_credential_id(
|
||||
&self,
|
||||
ctx: Arc<dyn AppContextTrait>,
|
||||
credential_id: i32,
|
||||
) -> RecorderResult<Self> {
|
||||
let mut fork = self.http_client.fork();
|
||||
|
||||
let credential = credential_3rd::Model::find_by_id(ctx.clone(), credential_id).await?;
|
||||
if let Some(credential) = credential {
|
||||
if credential.credential_type != Credential3rdType::Mikan {
|
||||
@@ -203,20 +219,7 @@ impl MikanClient {
|
||||
let userpass_credential: UserPassCredential =
|
||||
credential.try_into_userpass_credential(ctx)?;
|
||||
|
||||
fork = fork.attach_cookies(userpass_credential.cookies.as_deref())?;
|
||||
|
||||
if let Some(user_agent) = userpass_credential.user_agent.as_ref() {
|
||||
fork = fork.attach_user_agent(user_agent);
|
||||
}
|
||||
|
||||
let userpass_credential_opt = Some(userpass_credential);
|
||||
|
||||
Ok(Self {
|
||||
http_client: HttpClient::from_fork(fork)?,
|
||||
base_url: self.base_url.clone(),
|
||||
origin_url: self.origin_url.clone(),
|
||||
userpass_credential: userpass_credential_opt,
|
||||
})
|
||||
self.fork_with_credential(userpass_credential).await
|
||||
} else {
|
||||
Err(RecorderError::from_db_record_not_found(
|
||||
DbErr::RecordNotFound(format!("credential={credential_id} not found")),
|
||||
@@ -319,7 +322,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mikan_client = mikan_client
|
||||
.fork_with_credential(app_ctx.clone(), credential_model.id)
|
||||
.fork_with_credential_id(app_ctx.clone(), credential_model.id)
|
||||
.await?;
|
||||
|
||||
mikan_client.login().await?;
|
||||
|
||||
@@ -5,4 +5,13 @@ pub const MIKAN_LOGIN_PAGE_PATH: &str = "/Account/Login";
|
||||
pub const MIKAN_LOGIN_PAGE_SEARCH: &str = "ReturnUrl=%2F";
|
||||
pub const MIKAN_ACCOUNT_MANAGE_PAGE_PATH: &str = "/Account/Manage";
|
||||
pub const MIKAN_SEASON_FLOW_PAGE_PATH: &str = "/Home/BangumiCoverFlow";
|
||||
pub const MIKAN_BANGUMI_HOMEPAGE_PATH: &str = "/Home/Bangumi";
|
||||
pub const MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH: &str = "/Home/ExpandBangumi";
|
||||
pub const MIKAN_EPISODE_HOMEPAGE_PATH: &str = "/Home/Episode";
|
||||
pub const MIKAN_BANGUMI_POSTER_PATH: &str = "/images/Bangumi";
|
||||
pub const MIKAN_EPISODE_TORRENT_PATH: &str = "/Download";
|
||||
pub const MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH: &str = "/RSS/MyBangumi";
|
||||
pub const MIKAN_BANGUMI_RSS_PATH: &str = "/RSS/Bangumi";
|
||||
pub const MIKAN_BANGUMI_ID_QUERY_KEY: &str = "bangumiId";
|
||||
pub const MIKAN_FANSUB_ID_QUERY_KEY: &str = "subgroupid";
|
||||
pub const MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY: &str = "token";
|
||||
|
||||
@@ -9,8 +9,12 @@ pub use client::MikanClient;
|
||||
pub use config::MikanConfig;
|
||||
pub use constants::{
|
||||
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH,
|
||||
MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH, MIKAN_POSTER_BUCKET_KEY,
|
||||
MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_UNKNOWN_FANSUB_ID, MIKAN_UNKNOWN_FANSUB_NAME,
|
||||
MIKAN_BANGUMI_HOMEPAGE_PATH, MIKAN_BANGUMI_ID_QUERY_KEY, MIKAN_BANGUMI_POSTER_PATH,
|
||||
MIKAN_BANGUMI_RSS_PATH, MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_EPISODE_TORRENT_PATH,
|
||||
MIKAN_FANSUB_ID_QUERY_KEY, MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH,
|
||||
MIKAN_POSTER_BUCKET_KEY, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH,
|
||||
MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY, MIKAN_UNKNOWN_FANSUB_ID,
|
||||
MIKAN_UNKNOWN_FANSUB_NAME,
|
||||
};
|
||||
pub use credential::MikanCredentialForm;
|
||||
pub use subscription::{
|
||||
|
||||
@@ -20,8 +20,11 @@ use crate::{
|
||||
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
|
||||
media::extract_image_src_from_str,
|
||||
mikan::{
|
||||
MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH, MIKAN_POSTER_BUCKET_KEY,
|
||||
MIKAN_SEASON_FLOW_PAGE_PATH, MikanClient,
|
||||
MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH, MIKAN_BANGUMI_HOMEPAGE_PATH,
|
||||
MIKAN_BANGUMI_ID_QUERY_KEY, MIKAN_BANGUMI_POSTER_PATH, MIKAN_BANGUMI_RSS_PATH,
|
||||
MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_FANSUB_ID_QUERY_KEY, MIKAN_POSTER_BUCKET_KEY,
|
||||
MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH,
|
||||
MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY, MikanClient,
|
||||
},
|
||||
},
|
||||
storage::{StorageContentCategory, StorageServiceTrait},
|
||||
@@ -101,12 +104,12 @@ pub struct MikanSubscriberSubscriptionRssUrlMeta {
|
||||
|
||||
impl MikanSubscriberSubscriptionRssUrlMeta {
|
||||
pub fn from_rss_url(url: &Url) -> Option<Self> {
|
||||
if url.path() == "/RSS/MyBangumi" {
|
||||
url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| {
|
||||
MikanSubscriberSubscriptionRssUrlMeta {
|
||||
if url.path() == MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH {
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY)
|
||||
.map(|(_, v)| MikanSubscriberSubscriptionRssUrlMeta {
|
||||
mikan_subscription_token: v.to_string(),
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -122,9 +125,11 @@ pub fn build_mikan_subscriber_subscription_rss_url(
|
||||
mikan_subscription_token: &str,
|
||||
) -> Url {
|
||||
let mut url = mikan_base_url;
|
||||
url.set_path("/RSS/MyBangumi");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("token", mikan_subscription_token);
|
||||
url.set_path(MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH);
|
||||
url.query_pairs_mut().append_pair(
|
||||
MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY,
|
||||
mikan_subscription_token,
|
||||
);
|
||||
url
|
||||
}
|
||||
|
||||
@@ -224,8 +229,10 @@ pub struct MikanBangumiIndexHash {
|
||||
|
||||
impl MikanBangumiIndexHash {
|
||||
pub fn from_homepage_url(url: &Url) -> Option<Self> {
|
||||
if url.path().starts_with("/Home/Bangumi/") {
|
||||
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
|
||||
if url.path().starts_with(MIKAN_BANGUMI_HOMEPAGE_PATH) {
|
||||
let mikan_bangumi_id = url
|
||||
.path()
|
||||
.replace(&format!("{MIKAN_BANGUMI_HOMEPAGE_PATH}/"), "");
|
||||
|
||||
Some(Self { mikan_bangumi_id })
|
||||
} else {
|
||||
@@ -244,12 +251,12 @@ pub fn build_mikan_bangumi_subscription_rss_url(
|
||||
mikan_fansub_id: Option<&str>,
|
||||
) -> Url {
|
||||
let mut url = mikan_base_url;
|
||||
url.set_path("/RSS/Bangumi");
|
||||
url.set_path(MIKAN_BANGUMI_RSS_PATH);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("bangumiId", mikan_bangumi_id);
|
||||
.append_pair(MIKAN_BANGUMI_ID_QUERY_KEY, mikan_bangumi_id);
|
||||
if let Some(mikan_fansub_id) = mikan_fansub_id {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("subgroupid", mikan_fansub_id);
|
||||
.append_pair(MIKAN_FANSUB_ID_QUERY_KEY, mikan_fansub_id);
|
||||
};
|
||||
url
|
||||
}
|
||||
@@ -262,8 +269,10 @@ pub struct MikanBangumiHash {
|
||||
|
||||
impl MikanBangumiHash {
|
||||
pub fn from_homepage_url(url: &Url) -> Option<Self> {
|
||||
if url.path().starts_with("/Home/Bangumi/") {
|
||||
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
|
||||
if url.path().starts_with(MIKAN_BANGUMI_HOMEPAGE_PATH) {
|
||||
let mikan_bangumi_id = url
|
||||
.path()
|
||||
.replace(&format!("{MIKAN_BANGUMI_HOMEPAGE_PATH}/"), "");
|
||||
|
||||
let url_fragment = url.fragment()?;
|
||||
|
||||
@@ -277,13 +286,13 @@ impl MikanBangumiHash {
|
||||
}
|
||||
|
||||
pub fn from_rss_url(url: &Url) -> Option<Self> {
|
||||
if url.path() == "/RSS/Bangumi" {
|
||||
if url.path() == MIKAN_BANGUMI_RSS_PATH {
|
||||
if let (Some(mikan_fansub_id), Some(mikan_bangumi_id)) = (
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == "subgroupid")
|
||||
.find(|(k, _)| k == MIKAN_FANSUB_ID_QUERY_KEY)
|
||||
.map(|(_, v)| v.to_string()),
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == "bangumiId")
|
||||
.find(|(k, _)| k == MIKAN_BANGUMI_ID_QUERY_KEY)
|
||||
.map(|(_, v)| v.to_string()),
|
||||
) {
|
||||
Some(Self {
|
||||
@@ -317,7 +326,7 @@ impl MikanBangumiHash {
|
||||
|
||||
pub fn build_mikan_episode_homepage_url(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
|
||||
let mut url = mikan_base_url;
|
||||
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
|
||||
url.set_path(&format!("{MIKAN_EPISODE_HOMEPAGE_PATH}/{mikan_episode_id}"));
|
||||
url
|
||||
}
|
||||
|
||||
@@ -328,8 +337,10 @@ pub struct MikanEpisodeHash {
|
||||
|
||||
impl MikanEpisodeHash {
|
||||
pub fn from_homepage_url(url: &Url) -> Option<Self> {
|
||||
if url.path().starts_with("/Home/Episode/") {
|
||||
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
|
||||
if url.path().starts_with(MIKAN_EPISODE_HOMEPAGE_PATH) {
|
||||
let mikan_episode_id = url
|
||||
.path()
|
||||
.replace(&format!("{MIKAN_EPISODE_HOMEPAGE_PATH}/"), "");
|
||||
Some(Self { mikan_episode_id })
|
||||
} else {
|
||||
None
|
||||
@@ -416,7 +427,7 @@ pub fn build_mikan_bangumi_homepage_url(
|
||||
mikan_fansub_id: Option<&str>,
|
||||
) -> Url {
|
||||
let mut url = mikan_base_url;
|
||||
url.set_path(&format!("/Home/Bangumi/{mikan_bangumi_id}"));
|
||||
url.set_path(&format!("{MIKAN_BANGUMI_HOMEPAGE_PATH}/{mikan_bangumi_id}"));
|
||||
url.set_fragment(mikan_fansub_id);
|
||||
url
|
||||
}
|
||||
@@ -715,7 +726,9 @@ pub async fn scrape_mikan_poster_meta_from_image_url(
|
||||
StorageContentCategory::Image,
|
||||
subscriber_id,
|
||||
Some(MIKAN_POSTER_BUCKET_KEY),
|
||||
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
|
||||
&origin_poster_src_url
|
||||
.path()
|
||||
.replace(&format!("{MIKAN_BANGUMI_POSTER_PATH}/"), ""),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -734,7 +747,9 @@ pub async fn scrape_mikan_poster_meta_from_image_url(
|
||||
StorageContentCategory::Image,
|
||||
subscriber_id,
|
||||
Some(MIKAN_POSTER_BUCKET_KEY),
|
||||
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
|
||||
&origin_poster_src_url
|
||||
.path()
|
||||
.replace(&format!("{MIKAN_BANGUMI_POSTER_PATH}/"), ""),
|
||||
poster_data,
|
||||
)
|
||||
.await?;
|
||||
@@ -884,7 +899,7 @@ pub fn scrape_mikan_bangumi_meta_stream_from_season_flow_url(
|
||||
) -> impl Stream<Item = RecorderResult<MikanBangumiMeta>> {
|
||||
try_stream! {
|
||||
let mikan_base_url = ctx.mikan().base_url().clone();
|
||||
let mikan_client = ctx.mikan().fork_with_credential(ctx.clone(), credential_id).await?;
|
||||
let mikan_client = ctx.mikan().fork_with_credential_id(ctx.clone(), credential_id).await?;
|
||||
|
||||
let content = fetch_html(&mikan_client, mikan_season_flow_url.clone()).await?;
|
||||
|
||||
@@ -971,7 +986,8 @@ mod test {
|
||||
crypto::build_testing_crypto_service,
|
||||
database::build_testing_database_service,
|
||||
mikan::{
|
||||
MikanMockServer, build_testing_mikan_client, build_testing_mikan_credential_form,
|
||||
MikanMockServer, build_testing_mikan_client, build_testing_mikan_credential,
|
||||
build_testing_mikan_credential_form,
|
||||
},
|
||||
storage::build_testing_storage_service,
|
||||
tracing::try_init_testing_tracing,
|
||||
@@ -986,22 +1002,19 @@ mod test {
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_scrape_mikan_poster_data_from_image_url(before_each: ()) -> RecorderResult<()> {
|
||||
let mut mikan_server = mockito::Server::new_async().await;
|
||||
let mikan_base_url = Url::parse(&mikan_server.url())?;
|
||||
let mut mikan_server = MikanMockServer::new().await?;
|
||||
|
||||
let resources_mock = mikan_server.mock_resources_with_doppel();
|
||||
|
||||
let mikan_base_url = mikan_server.base_url().clone();
|
||||
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
|
||||
|
||||
let bangumi_poster_url = mikan_base_url.join("/images/Bangumi/202309/5ce9fed1.jpg")?;
|
||||
|
||||
let bangumi_poster_mock = mikan_server
|
||||
.mock("GET", bangumi_poster_url.path())
|
||||
.with_body_from_file("tests/resources/mikan/Bangumi-202309-5ce9fed1.jpg")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let bgm_poster_data =
|
||||
scrape_mikan_poster_data_from_image_url(&mikan_client, bangumi_poster_url).await?;
|
||||
|
||||
bangumi_poster_mock.expect(1);
|
||||
resources_mock.shared_resource_mock.expect(1);
|
||||
let image = Image::read(bgm_poster_data.to_vec(), Default::default());
|
||||
assert!(
|
||||
image.is_ok_and(|img| img
|
||||
@@ -1017,20 +1030,19 @@ mod test {
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_scrape_mikan_poster_meta_from_image_url(before_each: ()) -> RecorderResult<()> {
|
||||
let mut mikan_server = mockito::Server::new_async().await;
|
||||
let mikan_base_url = Url::parse(&mikan_server.url())?;
|
||||
let mut mikan_server = MikanMockServer::new().await?;
|
||||
|
||||
let mikan_base_url = mikan_server.base_url().clone();
|
||||
|
||||
let resources_mock = mikan_server.mock_resources_with_doppel();
|
||||
|
||||
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
|
||||
|
||||
let storage_service = build_testing_storage_service().await?;
|
||||
let storage_operator = storage_service.get_operator()?;
|
||||
|
||||
let bangumi_poster_url = mikan_base_url.join("/images/Bangumi/202309/5ce9fed1.jpg")?;
|
||||
|
||||
let bangumi_poster_mock = mikan_server
|
||||
.mock("GET", bangumi_poster_url.path())
|
||||
.with_body_from_file("tests/resources/mikan/Bangumi-202309-5ce9fed1.jpg")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let bgm_poster = scrape_mikan_poster_meta_from_image_url(
|
||||
&mikan_client,
|
||||
&storage_service,
|
||||
@@ -1039,7 +1051,7 @@ mod test {
|
||||
)
|
||||
.await?;
|
||||
|
||||
bangumi_poster_mock.expect(1);
|
||||
resources_mock.shared_resource_mock.expect(1);
|
||||
|
||||
let storage_fullname = storage_service.get_fullname(
|
||||
StorageContentCategory::Image,
|
||||
@@ -1051,7 +1063,8 @@ mod test {
|
||||
|
||||
assert!(storage_operator.exists(storage_fullename_str).await?);
|
||||
|
||||
let expected_data = fs::read("tests/resources/mikan/Bangumi-202309-5ce9fed1.jpg")?;
|
||||
let expected_data =
|
||||
fs::read("tests/resources/mikan/doppel/images/Bangumi/202309/5ce9fed1.jpg")?;
|
||||
let found_data = storage_operator.read(storage_fullename_str).await?.to_vec();
|
||||
assert_eq!(expected_data, found_data);
|
||||
|
||||
@@ -1100,7 +1113,7 @@ mod test {
|
||||
before_each: (),
|
||||
) -> RecorderResult<()> {
|
||||
let fragment_str =
|
||||
fs::read_to_string("tests/resources/mikan/BangumiCoverFlow-2025-spring-noauth.html")?;
|
||||
fs::read_to_string("tests/resources/mikan/BangumiCoverFlow-noauth.html")?;
|
||||
|
||||
let bangumi_index_meta_list =
|
||||
extract_mikan_bangumi_index_meta_list_from_season_flow_fragment(
|
||||
@@ -1114,12 +1127,27 @@ mod test {
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[test]
|
||||
fn test_extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
|
||||
#[tokio::test]
|
||||
async fn test_extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
|
||||
before_each: (),
|
||||
) -> RecorderResult<()> {
|
||||
let mut mikan_server = MikanMockServer::new().await?;
|
||||
|
||||
let login_mock = mikan_server.mock_get_login_page();
|
||||
let resources_mock = mikan_server.mock_resources_with_doppel();
|
||||
|
||||
let mikan_base_url = mikan_server.base_url().clone();
|
||||
|
||||
let mikan_client = build_testing_mikan_client(mikan_base_url.clone())
|
||||
.await?
|
||||
.fork_with_credential(build_testing_mikan_credential())
|
||||
.await?;
|
||||
|
||||
mikan_client.login().await?;
|
||||
|
||||
let origin_poster_src =
|
||||
Url::parse("https://mikanani.me/images/Bangumi/202504/076c1094.jpg")?;
|
||||
|
||||
let bangumi_index_meta = MikanBangumiIndexMeta {
|
||||
homepage: Url::parse("https://mikanani.me/Home/Bangumi/3599")?,
|
||||
origin_poster_src: Some(origin_poster_src.clone()),
|
||||
@@ -1127,10 +1155,17 @@ mod test {
|
||||
mikan_bangumi_id: "3599".to_string(),
|
||||
};
|
||||
|
||||
let fragment_str = fs::read_to_string("tests/resources/mikan/ExpandBangumi-3599.html")?;
|
||||
let fragment_str = fetch_html(
|
||||
&mikan_client,
|
||||
build_mikan_bangumi_expand_subscribed_url(
|
||||
mikan_base_url.clone(),
|
||||
&bangumi_index_meta.mikan_bangumi_id,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bangumi = extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
|
||||
&Html::parse_document(&fragment_str),
|
||||
&Html::parse_fragment(&fragment_str),
|
||||
bangumi_index_meta.clone(),
|
||||
Url::parse("https://mikanani.me/")?,
|
||||
)
|
||||
@@ -1175,7 +1210,7 @@ mod test {
|
||||
fs::read_to_string("tests/resources/mikan/ExpandBangumi-3599-noauth.html")?;
|
||||
|
||||
let bangumi = extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
|
||||
&Html::parse_document(&fragment_str),
|
||||
&Html::parse_fragment(&fragment_str),
|
||||
bangumi_index_meta.clone(),
|
||||
Url::parse("https://mikanani.me/")?,
|
||||
);
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{self, Path},
|
||||
};
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use fetch::{FetchError, HttpClientConfig, IntoUrl, get_random_ua};
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode, utf8_percent_encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
crypto::UserPassCredential,
|
||||
errors::RecorderResult,
|
||||
extract::mikan::{
|
||||
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_LOGIN_PAGE_PATH, MikanClient, MikanConfig,
|
||||
MikanCredentialForm,
|
||||
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH,
|
||||
MIKAN_BANGUMI_HOMEPAGE_PATH, MIKAN_BANGUMI_POSTER_PATH, MIKAN_BANGUMI_RSS_PATH,
|
||||
MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_EPISODE_TORRENT_PATH, MIKAN_LOGIN_PAGE_PATH,
|
||||
MIKAN_SEASON_FLOW_PAGE_PATH, MikanClient, MikanConfig, MikanCredentialForm,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +25,20 @@ const TESTING_MIKAN_PASSWORD: &str = "test_password";
|
||||
const TESTING_MIKAN_ANTIFORGERY: &str = "test_antiforgery";
|
||||
const TESTING_MIKAN_IDENTITY: &str = "test_identity";
|
||||
|
||||
const FILE_UNSAFE: &AsciiSet = &CONTROLS
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b':')
|
||||
.add(b'"')
|
||||
.add(b'|')
|
||||
.add(b'?')
|
||||
.add(b'*')
|
||||
.add(b'\\')
|
||||
.add(b'/')
|
||||
.add(b'&')
|
||||
.add(b'=')
|
||||
.add(b'#');
|
||||
|
||||
pub async fn build_testing_mikan_client(
|
||||
base_mikan_url: impl IntoUrl,
|
||||
) -> RecorderResult<MikanClient> {
|
||||
@@ -38,6 +60,120 @@ pub fn build_testing_mikan_credential_form() -> MikanCredentialForm {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_testing_mikan_credential() -> UserPassCredential {
|
||||
UserPassCredential {
|
||||
username: String::from(TESTING_MIKAN_USERNAME),
|
||||
password: String::from(TESTING_MIKAN_PASSWORD),
|
||||
user_agent: None,
|
||||
cookies: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MikanDoppelMeta {
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
pub struct MikanDoppelPath {
|
||||
path: path::PathBuf,
|
||||
}
|
||||
|
||||
impl MikanDoppelPath {
|
||||
pub fn new(source: impl Into<Self>) -> Self {
|
||||
source.into()
|
||||
}
|
||||
|
||||
pub fn exists_any(&self) -> bool {
|
||||
self.exists() || self.exists_meta()
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.path().exists()
|
||||
}
|
||||
|
||||
pub fn exists_meta(&self) -> bool {
|
||||
self.meta_path().exists()
|
||||
}
|
||||
|
||||
pub fn write(&self, content: impl AsRef<[u8]>) -> std::io::Result<()> {
|
||||
if let Some(parent) = self.as_ref().parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(self.as_ref(), content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_meta(&self, meta: MikanDoppelMeta) -> std::io::Result<()> {
|
||||
self.write(serde_json::to_string(&meta)?)
|
||||
}
|
||||
|
||||
pub fn read(&self) -> std::io::Result<Vec<u8>> {
|
||||
let content = std::fs::read(self.as_ref())?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub fn read_meta(&self) -> std::io::Result<MikanDoppelMeta> {
|
||||
let content = std::fs::read(self.meta_path())?;
|
||||
Ok(serde_json::from_slice(&content)?)
|
||||
}
|
||||
|
||||
pub fn encode_path_component(component: &str) -> String {
|
||||
utf8_percent_encode(component, FILE_UNSAFE).to_string()
|
||||
}
|
||||
|
||||
pub fn decode_path_component(component: &str) -> Result<String, std::str::Utf8Error> {
|
||||
Ok(percent_decode(component.as_bytes())
|
||||
.decode_utf8()?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub fn meta_path(&self) -> path::PathBuf {
|
||||
let extension = if let Some(ext) = self.path().extension() {
|
||||
format!("{}.meta.json", ext.to_string_lossy())
|
||||
} else {
|
||||
String::from("meta.json")
|
||||
};
|
||||
self.path.to_path_buf().with_extension(extension)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &path::Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<path::Path> for MikanDoppelPath {
|
||||
fn as_ref(&self) -> &path::Path {
|
||||
self.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Url> for MikanDoppelPath {
|
||||
fn from(value: Url) -> Self {
|
||||
let base_path =
|
||||
Path::new("tests/resources/mikan/doppel").join(value.path().trim_matches('/'));
|
||||
let dirname = base_path.parent();
|
||||
let stem = base_path.file_stem();
|
||||
debug_assert!(dirname.is_some() && stem.is_some());
|
||||
let extension = if let Some(ext) = base_path.extension() {
|
||||
ext.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::from("html")
|
||||
};
|
||||
let mut filename = stem.unwrap().to_string_lossy().to_string();
|
||||
if let Some(query) = value.query() {
|
||||
filename.push_str(&format!("-{}", Self::encode_path_component(query)));
|
||||
}
|
||||
if let Some(fragment) = value.fragment() {
|
||||
filename.push_str(&format!("-{}", Self::encode_path_component(fragment)));
|
||||
}
|
||||
filename.push_str(&format!(".{extension}"));
|
||||
|
||||
Self {
|
||||
path: dirname.unwrap().join(filename),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MikanMockServerLoginMock {
|
||||
pub login_get_mock: mockito::Mock,
|
||||
pub login_post_success_mock: mockito::Mock,
|
||||
@@ -46,6 +182,14 @@ pub struct MikanMockServerLoginMock {
|
||||
pub account_get_failed_mock: mockito::Mock,
|
||||
}
|
||||
|
||||
pub struct MikanMockServerResourcesMock {
|
||||
pub shared_resource_mock: mockito::Mock,
|
||||
pub shared_resource_not_found_mock: mockito::Mock,
|
||||
pub user_resource_mock: mockito::Mock,
|
||||
pub expand_bangumi_noauth_mock: mockito::Mock,
|
||||
pub season_flow_noauth_mock: mockito::Mock,
|
||||
}
|
||||
|
||||
pub struct MikanMockServer {
|
||||
pub server: mockito::ServerGuard,
|
||||
base_url: Url,
|
||||
@@ -80,7 +224,7 @@ impl MikanMockServer {
|
||||
.server
|
||||
.mock("GET", MIKAN_LOGIN_PAGE_PATH)
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(201)
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "text/html; charset=utf-8")
|
||||
.with_header(
|
||||
"Set-Cookie",
|
||||
@@ -89,6 +233,7 @@ impl MikanMockServer {
|
||||
SameSite=Strict; Path=/"
|
||||
),
|
||||
)
|
||||
.with_body_from_file("tests/resources/mikan/LoginPage.html")
|
||||
.create();
|
||||
|
||||
let test_identity_expires = (Utc::now() + Duration::days(30)).to_rfc2822();
|
||||
@@ -170,4 +315,138 @@ impl MikanMockServer {
|
||||
account_get_failed_mock,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock_resources_with_doppel(&mut self) -> MikanMockServerResourcesMock {
|
||||
let shared_resource_mock = self
|
||||
.server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_request({
|
||||
let mikan_base_url = self.base_url().clone();
|
||||
move |request| {
|
||||
let path = request.path();
|
||||
if path.starts_with(MIKAN_BANGUMI_RSS_PATH)
|
||||
|| path.starts_with(MIKAN_BANGUMI_HOMEPAGE_PATH)
|
||||
|| path.starts_with(MIKAN_EPISODE_HOMEPAGE_PATH)
|
||||
|| path.starts_with(MIKAN_BANGUMI_POSTER_PATH)
|
||||
|| path.starts_with(MIKAN_EPISODE_TORRENT_PATH)
|
||||
{
|
||||
if let Ok(url) = mikan_base_url.join(request.path_and_query()) {
|
||||
let doppel_path = MikanDoppelPath::from(url);
|
||||
doppel_path.exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_status(200)
|
||||
.with_body_from_request({
|
||||
let mikan_base_url = self.base_url().clone();
|
||||
move |req| {
|
||||
let path_and_query = req.path_and_query();
|
||||
let url = mikan_base_url.join(path_and_query).unwrap();
|
||||
let doppel_path = MikanDoppelPath::from(url);
|
||||
doppel_path.read().unwrap()
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
let shared_resource_not_found_mock = self
|
||||
.server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_request({
|
||||
let mikan_base_url = self.base_url().clone();
|
||||
move |request| {
|
||||
let path = request.path();
|
||||
if path.starts_with(MIKAN_BANGUMI_RSS_PATH)
|
||||
|| path.starts_with(MIKAN_BANGUMI_HOMEPAGE_PATH)
|
||||
|| path.starts_with(MIKAN_EPISODE_HOMEPAGE_PATH)
|
||||
|| path.starts_with(MIKAN_BANGUMI_POSTER_PATH)
|
||||
|| path.starts_with(MIKAN_EPISODE_TORRENT_PATH)
|
||||
{
|
||||
if let Ok(url) = mikan_base_url.join(request.path_and_query()) {
|
||||
let doppel_path = MikanDoppelPath::from(url);
|
||||
doppel_path.exists_meta()
|
||||
&& doppel_path.read_meta().unwrap().status == 404
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_status(404)
|
||||
.create();
|
||||
|
||||
let user_resource_mock = self
|
||||
.server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_request({
|
||||
let mikan_base_url = self.base_url().clone();
|
||||
move |req| {
|
||||
if !Self::get_has_auth_matcher()(req) {
|
||||
return false;
|
||||
}
|
||||
let path = req.path();
|
||||
if path.starts_with(MIKAN_SEASON_FLOW_PAGE_PATH)
|
||||
|| path.starts_with(MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH)
|
||||
{
|
||||
if let Ok(url) = mikan_base_url.join(req.path_and_query()) {
|
||||
let doppel_path = MikanDoppelPath::from(url);
|
||||
doppel_path.exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_status(200)
|
||||
.with_body_from_request({
|
||||
let mikan_base_url = self.base_url().clone();
|
||||
move |req| {
|
||||
let path_and_query = req.path_and_query();
|
||||
let url = mikan_base_url.join(path_and_query).unwrap();
|
||||
let doppel_path = MikanDoppelPath::from(url);
|
||||
doppel_path.read().unwrap()
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
let expand_bangumi_noauth_mock = self
|
||||
.server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_request(move |req| {
|
||||
!Self::get_has_auth_matcher()(req)
|
||||
&& req
|
||||
.path()
|
||||
.starts_with(MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH)
|
||||
})
|
||||
.with_status(200)
|
||||
.with_body_from_file("tests/resources/mikan/ExpandBangumi-noauth.html")
|
||||
.create();
|
||||
|
||||
let season_flow_noauth_mock = self
|
||||
.server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_request(move |req| {
|
||||
!Self::get_has_auth_matcher()(req)
|
||||
&& req.path().starts_with(MIKAN_SEASON_FLOW_PAGE_PATH)
|
||||
})
|
||||
.with_status(200)
|
||||
.with_body_from_file("tests/resources/mikan/BangumiCoverFlow-noauth.html")
|
||||
.create();
|
||||
|
||||
MikanMockServerResourcesMock {
|
||||
shared_resource_mock,
|
||||
shared_resource_not_found_mock,
|
||||
user_resource_mock,
|
||||
expand_bangumi_noauth_mock,
|
||||
season_flow_noauth_mock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user