use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::api::auth_matrix::{Endpoint, endpoints};
use crate::api::{Client, ClientBuilder};
use crate::auth::OAuth2Credential;
pub const MOCK_BEARER_TOKEN: &str = "mock-bearer-token";
const FIXTURES: &str = include_str!("../../tests/fixtures/openapi/example_responses.json");
const RATE_LIMIT: u32 = 450;
const RATE_LIMIT_REMAINING: u32 = 449;
const RATE_LIMIT_WINDOW_SECS: u64 = 900;
struct Route {
endpoint: Endpoint,
fixture: &'static str,
status: u16,
}
const ROUTES: &[Route] = &[
Route {
endpoint: endpoints::CREATE_POST,
fixture: "post_single",
status: 201,
},
Route {
endpoint: endpoints::READ_POST,
fixture: "post_single",
status: 200,
},
Route {
endpoint: endpoints::DELETE_POST,
fixture: "action_deleted",
status: 200,
},
Route {
endpoint: endpoints::SEARCH_POSTS,
fixture: "post_list",
status: 200,
},
Route {
endpoint: endpoints::GET_ME,
fixture: "user_single",
status: 200,
},
Route {
endpoint: endpoints::LOOKUP_USER,
fixture: "user_single",
status: 200,
},
Route {
endpoint: endpoints::GET_TIMELINE,
fixture: "post_list",
status: 200,
},
Route {
endpoint: endpoints::GET_MENTIONS,
fixture: "post_list",
status: 200,
},
Route {
endpoint: endpoints::GET_FOLLOWERS,
fixture: "user_list",
status: 200,
},
Route {
endpoint: endpoints::GET_LIKED_POSTS,
fixture: "post_list",
status: 200,
},
Route {
endpoint: endpoints::LIKE_POST,
fixture: "action_liked",
status: 200,
},
Route {
endpoint: endpoints::UNLIKE_POST,
fixture: "action_liked",
status: 200,
},
Route {
endpoint: endpoints::REPOST,
fixture: "action_retweeted",
status: 200,
},
Route {
endpoint: endpoints::UNREPOST,
fixture: "action_retweeted",
status: 200,
},
Route {
endpoint: endpoints::GET_BOOKMARKS,
fixture: "post_list",
status: 200,
},
Route {
endpoint: endpoints::BOOKMARK,
fixture: "action_bookmarked",
status: 200,
},
Route {
endpoint: endpoints::UNBOOKMARK,
fixture: "action_bookmarked",
status: 200,
},
Route {
endpoint: endpoints::GET_FOLLOWING,
fixture: "user_list",
status: 200,
},
Route {
endpoint: endpoints::FOLLOW_USER,
fixture: "action_following",
status: 200,
},
Route {
endpoint: endpoints::UNFOLLOW_USER,
fixture: "action_following",
status: 200,
},
Route {
endpoint: endpoints::GET_MUTED,
fixture: "user_list",
status: 200,
},
Route {
endpoint: endpoints::MUTE_USER,
fixture: "action_muting",
status: 200,
},
Route {
endpoint: endpoints::UNMUTE_USER,
fixture: "action_muting",
status: 200,
},
Route {
endpoint: endpoints::GET_BLOCKED,
fixture: "user_list",
status: 200,
},
Route {
endpoint: endpoints::BLOCK_USER,
fixture: "action_blocking",
status: 200,
},
Route {
endpoint: endpoints::UNBLOCK_USER,
fixture: "action_blocking",
status: 200,
},
Route {
endpoint: endpoints::SEND_DM,
fixture: "dm_sent",
status: 201,
},
Route {
endpoint: endpoints::GET_DM_EVENTS,
fixture: "dm_event_list",
status: 200,
},
Route {
endpoint: endpoints::GET_USAGE,
fixture: "usage",
status: 200,
},
Route {
endpoint: endpoints::GET_USAGE_CREDITS,
fixture: "usage_credits",
status: 200,
},
Route {
endpoint: endpoints::MEDIA_UPLOAD,
fixture: "media_upload_init",
status: 200,
},
Route {
endpoint: endpoints::MEDIA_UPLOAD_STATUS,
fixture: "media_upload_status",
status: 200,
},
Route {
endpoint: endpoints::MEDIA_UPLOAD_INITIALIZE,
fixture: "media_upload_init",
status: 200,
},
Route {
endpoint: endpoints::MEDIA_UPLOAD_APPEND,
fixture: "media_upload_append",
status: 200,
},
Route {
endpoint: endpoints::MEDIA_UPLOAD_FINALIZE,
fixture: "media_upload_status",
status: 200,
},
Route {
endpoint: endpoints::GET_CHAT_MODERATORS,
fixture: "user_list",
status: 200,
},
Route {
endpoint: endpoints::ADD_CHAT_MODERATOR,
fixture: "chat_moderators",
status: 200,
},
Route {
endpoint: endpoints::REMOVE_CHAT_MODERATOR,
fixture: "chat_moderators",
status: 200,
},
];
fn pattern(path: &str) -> String {
let widened: Vec<String> = path
.split('/')
.map(|segment| match segment {
"{username}" => "[^/]+".to_string(),
s if s.starts_with('{') && s.ends_with('}') => "[0-9]+".to_string(),
s => s.to_string(),
})
.collect();
format!("^{}$", widened.join("/"))
}
fn fixtures() -> &'static Value {
static PARSED: OnceLock<Value> = OnceLock::new();
PARSED.get_or_init(|| {
serde_json::from_str(FIXTURES).expect("the bundled fixture file is valid JSON")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Received {
pub method: String,
pub path: String,
pub query: Option<String>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
pub struct MockX {
server: MockServer,
}
impl std::fmt::Debug for MockX {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MockX")
.field("base_url", &self.server.uri())
.finish()
}
}
impl MockX {
pub async fn start() -> Self {
let server = MockServer::start().await;
let reset_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() + RATE_LIMIT_WINDOW_SECS)
.unwrap_or(RATE_LIMIT_WINDOW_SECS);
for route in ROUTES {
let template = ResponseTemplate::new(route.status)
.set_body_json(fixtures()[route.fixture].clone())
.insert_header("x-rate-limit-limit", RATE_LIMIT.to_string().as_str())
.insert_header(
"x-rate-limit-remaining",
RATE_LIMIT_REMAINING.to_string().as_str(),
)
.insert_header("x-rate-limit-reset", reset_at.to_string().as_str());
Mock::given(method(route.endpoint.method))
.and(path_regex(pattern(route.endpoint.path)))
.respond_with(template)
.mount(&server)
.await;
}
Self { server }
}
#[must_use]
pub fn base_url(&self) -> String {
self.server.uri()
}
pub fn builder(&self) -> ClientBuilder {
Client::builder().base_url(self.base_url())
}
pub fn app_client(&self) -> crate::Result<Client> {
self.builder().bearer(MOCK_BEARER_TOKEN).build()
}
pub fn user_client(&self) -> crate::Result<Client> {
self.builder()
.bearer(MOCK_BEARER_TOKEN)
.oauth2(OAuth2Credential {
client_id: "mock-client-id".to_string(),
client_secret: "mock-client-secret".to_string(),
access_token: "mock-access-token".to_string(),
refresh_token: None,
expires_at: None,
})
.build()
}
pub async fn stub(&self, method_name: &str, path_pattern: &str, status: u16, body: Value) {
Mock::given(method(method_name))
.and(path_regex(path_pattern))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.with_priority(1)
.mount(&self.server)
.await;
}
#[must_use]
pub fn fixture(name: &str) -> Option<Value> {
fixtures().get(name).cloned()
}
pub async fn requests(&self) -> Vec<Received> {
self.server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.map(|req| Received {
method: req.method.to_string(),
path: req.url.path().to_string(),
query: req.url.query().map(str::to_string),
headers: req
.headers
.iter()
.map(|(name, value)| {
(
name.to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect(),
body: req.body,
})
.collect()
}
}