use std::sync::Arc;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use crate::config::BotConfig;
use crate::error::{BotError, Result};
use crate::model::*;
#[derive(Debug, Clone)]
pub struct FichubClient {
http: reqwest::Client,
config: Arc<BotConfig>,
}
impl FichubClient {
pub fn new(config: Arc<BotConfig>) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent("fanfic-archivist/0.1 (FicHub Discord bot)")
.build()
.map_err(BotError::Http)?;
Ok(Self { http, config })
}
pub fn with_client(config: Arc<BotConfig>, http: reqwest::Client) -> Self {
Self { http, config }
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
async fn get<T: DeserializeOwned>(
&self,
path: &str,
auth_token: Option<&str>,
) -> Result<T> {
let url = self.config.url(path);
let mut req = self.http.get(&url);
if let Some(tok) = auth_token {
req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
}
let resp = req.send().await?;
Self::parse_response(resp).await
}
async fn post<T: DeserializeOwned, B: serde::Serialize + ?Sized>(
&self,
path: &str,
body: &B,
auth_token: Option<&str>,
) -> Result<T> {
let url = self.config.url(path);
let mut req = self.http.post(&url).header(CONTENT_TYPE, "application/json");
if let Some(tok) = auth_token {
req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
}
let resp = req.json(body).send().await?;
Self::parse_response(resp).await
}
async fn delete(
&self,
path: &str,
auth_token: Option<&str>,
) -> Result<serde_json::Value> {
let url = self.config.url(path);
let mut req = self.http.delete(&url);
if let Some(tok) = auth_token {
req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
}
let resp = req.send().await?;
Self::parse_response(resp).await
}
async fn parse_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(BotError::Status { status, body });
}
let value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| BotError::Json(e))?;
if let Some(err) = value.get("err").and_then(|e| e.as_i64()) {
if err != 0 {
let msg = value
.get("msg")
.and_then(|m| m.as_str())
.unwrap_or("unknown error")
.to_string();
return Err(BotError::Api {
err: err as i32,
msg,
});
}
}
serde_json::from_value(value).map_err(BotError::Json)
}
pub async fn fetch_export(&self, url: &str) -> Result<ExportResponse> {
let q = urlencoding::encode(url);
self.get(&format!("/api/epub?q={q}"), None).await
}
pub async fn lazy_convert(&self, url: &str, format: &str) -> Result<ConvertResponse> {
let q = urlencoding::encode(url);
self.get(&format!("/api/epub/convert?q={q}&format={format}"), None)
.await
}
pub async fn fetch_meta(&self, url: &str) -> Result<ExportResponse> {
let q = urlencoding::encode(url);
self.get(&format!("/api/meta?q={q}"), None).await
}
pub async fn recommendations(&self, url: &str, n: i32) -> Result<RecommendationsResponse> {
let q = urlencoding::encode(url);
self.get(&format!("/api/recommendations?q={q}&n={n}"), None)
.await
}
pub async fn personal_recommendations(
&self,
token: &str,
) -> Result<PersonalRecommendationsResponse> {
self.get("/api/recommendations/personal", Some(token)).await
}
pub async fn strategies(&self) -> Result<serde_json::Value> {
self.get("/api/recommendations/strategies", None).await
}
pub async fn search(
&self,
params: &SearchParams,
) -> Result<SearchResponse> {
self.get(&format!("/api/search?{}", params.to_query()), None)
.await
}
pub async fn ask(&self, q: &str) -> Result<AskResponse> {
self.post("/api/search/ask", &serde_json::json!({ "q": q }), None)
.await
}
pub async fn body_search(
&self,
q: &str,
page: usize,
per_page: usize,
) -> Result<BodySearchResponse> {
self.get(&format!("/api/search/body?q={q}&page={page}&per_page={per_page}"), None)
.await
}
pub async fn feed(&self, token: &str, page: i64) -> Result<FeedResponse> {
self.get(&format!("/api/v1/feed?page={page}"), Some(token)).await
}
pub async fn add_bookmark(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
self.post(
"/api/bookmarks",
&serde_json::json!({ "url_id": url_id }),
Some(token),
)
.await
}
pub async fn remove_bookmark(
&self,
token: &str,
work_id: i64,
) -> Result<serde_json::Value> {
self.delete(&format!("/api/bookmarks/{work_id}"), Some(token)).await
}
pub async fn rate(&self, token: &str, url_id: &str, stars: i32) -> Result<serde_json::Value> {
self.post(
"/api/ratings",
&serde_json::json!({ "url_id": url_id, "stars": stars }),
Some(token),
)
.await
}
pub async fn add_block(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
self.post(
"/api/blocks",
&serde_json::json!({ "url_id": url_id }),
Some(token),
)
.await
}
pub async fn remove_block(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
self.delete(&format!("/api/blocks/{url_id}"), Some(token)).await
}
pub async fn kudos(&self, work_id: i64) -> Result<serde_json::Value> {
self.get(&format!("/api/kudos/{work_id}"), None).await
}
pub async fn list_requests(&self, status: &str, page: i64) -> Result<RequestsResponse> {
self.get(&format!("/api/requests?status={status}&page={page}"), None)
.await
}
pub async fn create_request(
&self,
token: &str,
title: &str,
body: &str,
) -> Result<serde_json::Value> {
self.post(
"/api/requests",
&serde_json::json!({ "title": title, "body": body }),
Some(token),
)
.await
}
pub async fn consensus(&self) -> Result<ConsensusResponse> {
self.get("/api/roadmap/consensus", None).await
}
pub async fn login(&self, username: &str, password: &str) -> Result<AuthResponse> {
self.post(
"/api/auth/login",
&serde_json::json!({ "username": username, "password": password }),
None,
)
.await
}
pub async fn me(&self, token: &str) -> Result<MeResponse> {
self.get("/api/auth/me", Some(token)).await
}
pub async fn docs_ask(&self, q: &str, n: usize) -> Result<Vec<serde_json::Value>> {
let query = urlencoding::encode(q);
let value: serde_json::Value = self
.get(&format!("/api/docs/ask?q={query}&n={n}"), None)
.await?;
Ok(value
.get("sections")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default())
}
pub async fn forum_categories(&self, token: &str) -> Result<ForumCategoriesResponse> {
self.get("/api/forum/categories", Some(token)).await
}
pub async fn forum_topics(
&self,
token: &str,
category: Option<&str>,
cursor: Option<i64>,
limit: u32,
) -> Result<ForumTopicList> {
let mut path = format!("/api/forum/topics?limit={limit}");
if let Some(cat) = category {
path.push_str(&format!("&category={}", urlencoding::encode(cat)));
}
if let Some(c) = cursor {
path.push_str(&format!("&cursor={c}"));
}
self.get(&path, Some(token)).await
}
pub async fn forum_topic(
&self,
token: &str,
topic_id: i64,
after: Option<i64>,
) -> Result<ForumTopicDetail> {
let path = match after {
Some(a) => format!("/api/forum/topics/{topic_id}?after={a}"),
None => format!("/api/forum/topics/{topic_id}"),
};
self.get(&path, Some(token)).await
}
pub async fn forum_topic_by_slug(&self, token: &str, slug: &str) -> Result<ForumTopicDetail> {
let s = urlencoding::encode(slug);
self.get(&format!("/api/forum/topics/by-slug/{s}"), Some(token))
.await
}
pub async fn forum_create_topic(
&self,
token: &str,
title: &str,
category_slug: &str,
body: &str,
payload: Option<serde_json::Value>,
) -> Result<ForumCreateTopicResponse> {
let mut body_obj = serde_json::json!({
"title": title,
"category_slug": category_slug,
"body": body,
});
if let Some(p) = payload {
body_obj["payload"] = p;
}
self.post("/api/forum/topics", &body_obj, Some(token)).await
}
pub async fn forum_reply(
&self,
token: &str,
topic_id: i64,
body: &str,
quote_of: Option<i64>,
) -> Result<ForumPostCreated> {
let mut body_obj = serde_json::json!({ "body": body });
if let Some(q) = quote_of {
body_obj["quote_of"] = serde_json::json!(q);
}
self.post(
&format!("/api/forum/topics/{topic_id}/posts"),
&body_obj,
Some(token),
)
.await
}
pub async fn forum_follow(&self, token: &str, topic_id: i64) -> Result<ForumFollowState> {
self.post(
&format!("/api/forum/topics/{topic_id}/follow"),
&serde_json::json!({}),
Some(token),
)
.await
}
pub async fn forum_follow_state(&self, token: &str, topic_id: i64) -> Result<ForumFollowState> {
self.get(&format!("/api/forum/topics/{topic_id}/follow"), Some(token))
.await
}
pub async fn forum_mark_read(
&self,
token: &str,
topic_id: i64,
last_read_post_id: Option<i64>,
) -> Result<ForumMarkRead> {
let body = match last_read_post_id {
Some(n) => serde_json::json!({ "last_read_post_id": n }),
None => serde_json::json!({}),
};
self.post(
&format!("/api/forum/topics/{topic_id}/read"),
&body,
Some(token),
)
.await
}
pub async fn forum_search(
&self,
token: &str,
q: &str,
category: Option<&str>,
) -> Result<ForumSearchResponse> {
let mut path = format!("/api/forum/search?q={}", urlencoding::encode(q));
if let Some(cat) = category {
path.push_str(&format!("&category={}", urlencoding::encode(cat)));
}
self.get(&path, Some(token)).await
}
pub async fn forum_moderation_status(&self, token: &str) -> Result<ForumModerationStatus> {
self.get("/api/forum/moderation/status", Some(token)).await
}
pub async fn forum_moderation_queue(&self, token: &str) -> Result<ForumModerationQueue> {
self.get("/api/forum/moderation/queue", Some(token)).await
}
pub async fn forum_moderate(
&self,
token: &str,
post_id: i64,
reason: &str,
) -> Result<ForumModerateResponse> {
self.post(
&format!("/api/forum/posts/{post_id}/moderate"),
&serde_json::json!({ "reason": reason }),
Some(token),
)
.await
}
pub async fn forum_metamod_queue(&self, token: &str) -> Result<ForumMetamodQueue> {
self.get("/api/forum/metamod/queue", Some(token)).await
}
pub async fn forum_metamod_vote(
&self,
token: &str,
action_id: i64,
verdict: &str,
) -> Result<ForumMetamodVoteResponse> {
self.post(
&format!("/api/forum/metamod/{action_id}/vote"),
&serde_json::json!({ "verdict": verdict }),
Some(token),
)
.await
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchParams {
pub q: String,
pub page: Option<usize>,
pub per_page: Option<usize>,
pub min_words: Option<i64>,
pub max_words: Option<i64>,
pub complete: Option<bool>,
pub source: Option<String>,
pub fandom: Option<String>,
pub exclude_fandom: Option<String>,
pub main_char_attr: Option<String>,
pub min_kudos: Option<i64>,
pub sort: Option<String>,
pub include_tags: Option<String>,
pub exclude_tags: Option<String>,
}
impl SearchParams {
pub fn to_query(&self) -> String {
let mut qs = Vec::new();
qs.push(format!("q={}", urlencoding::encode(&self.q)));
if let Some(p) = self.page {
qs.push(format!("page={p}"));
}
if let Some(p) = self.per_page {
qs.push(format!("per_page={p}"));
}
if let Some(v) = self.min_words {
qs.push(format!("min_words={v}"));
}
if let Some(v) = self.max_words {
qs.push(format!("max_words={v}"));
}
if let Some(v) = self.complete {
qs.push(format!("complete={v}"));
}
if let Some(v) = &self.source {
qs.push(format!("source={}", urlencoding::encode(v)));
}
if let Some(v) = &self.fandom {
qs.push(format!("fandom={}", urlencoding::encode(v)));
}
if let Some(v) = &self.exclude_fandom {
qs.push(format!("exclude_fandom={}", urlencoding::encode(v)));
}
if let Some(v) = &self.main_char_attr {
qs.push(format!("main_char_attr={}", urlencoding::encode(v)));
}
if let Some(v) = self.min_kudos {
qs.push(format!("min_kudos={v}"));
}
if let Some(v) = &self.sort {
qs.push(format!("sort={}", urlencoding::encode(v)));
}
if let Some(v) = &self.include_tags {
qs.push(format!("include_tags={}", urlencoding::encode(v)));
}
if let Some(v) = &self.exclude_tags {
qs.push(format!("exclude_tags={}", urlencoding::encode(v)));
}
qs.join("&")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn search_params_query_builds() {
let p = SearchParams {
q: "harry potter".into(),
min_words: Some(50000),
complete: Some(true),
sort: Some("kudos".into()),
..Default::default()
};
let q = p.to_query();
assert!(q.contains("q=harry%20potter"));
assert!(q.contains("min_words=50000"));
assert!(q.contains("complete=true"));
assert!(q.contains("sort=kudos"));
}
#[test]
fn search_params_omits_none() {
let p = SearchParams {
q: "drarry".into(),
..Default::default()
};
let q = p.to_query();
assert_eq!(q, "q=drarry");
assert!(!q.contains("page="));
assert!(!q.contains("min_words="));
}
#[test]
fn search_params_urlencodes_values() {
let p = SearchParams {
q: "dark harry".into(),
main_char_attr: Some("Harry Potter|Dark Harry".into()),
..Default::default()
};
let q = p.to_query();
assert!(q.contains("main_char_attr=Harry%20Potter%7CDark%20Harry"));
}
#[tokio::test]
async fn search_hits_api_search_endpoint() {
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requested_path = Arc::new(std::sync::Mutex::new(String::new()));
let path_clone = Arc::clone(&requested_path);
let server_path = Arc::clone(&requested_path);
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]);
let path = req.split_whitespace().nth(1).unwrap_or("").to_string();
*server_path.lock().unwrap() = path;
let body = r#"{"total":0,"results":[],"page":1,"per_page":20,"facets":{}}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
});
let mut cfg = crate::config::BotConfig::default();
cfg.base_url = format!("http://{addr}");
let http = reqwest::Client::builder().build().unwrap();
let client = FichubClient::with_client(Arc::new(cfg), http);
let params = SearchParams { q: "jillian".into(), ..Default::default() };
let _ = client.search(¶ms).await;
server.await.unwrap();
let path = path_clone.lock().unwrap().clone();
assert!(
path.starts_with("/api/search?"),
"search() must request /api/search?..., got: {path}"
);
assert!(path.contains("q=jillian"), "path should carry q param, got: {path}");
}
async fn serve_once(
status: u16,
body: &'static str,
) -> (
tokio::task::JoinHandle<()>,
std::net::SocketAddr,
Arc<std::sync::Mutex<Option<(String, String, Option<String>)>>>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let recorded = Arc::new(std::sync::Mutex::new(None));
let rec = Arc::clone(&recorded);
let resp_len = body.len();
let handle = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let mut lines = req.lines();
let request_line = lines.next().unwrap_or("").to_string();
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("").to_string();
let body_str = req
.split_once("\r\n\r\n")
.map(|(_, b)| b.to_string())
.unwrap_or_default();
let req_body = if body_str.is_empty() { None } else { Some(body_str) };
*rec.lock().unwrap() = Some((method, path, req_body));
let resp = format!(
"HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {resp_len}\r\nconnection: close\r\n\r\n{body}"
);
let _ = sock.write_all(resp.as_bytes()).await;
});
(handle, addr, recorded)
}
fn client_at(addr: std::net::SocketAddr) -> FichubClient {
let mut cfg = crate::config::BotConfig::default();
cfg.base_url = format!("http://{addr}");
let http = reqwest::Client::builder().build().unwrap();
FichubClient::with_client(Arc::new(cfg), http)
}
#[tokio::test]
async fn forum_categories_sends_bearer_and_parses() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"items":[{"id":1,"slug":"recs","title":"Recs","description":"","position":0,"is_mod_only":false,"created_at":"2026-01-01T00:00:00Z","topic_count":3,"last_activity_at":null}]}"#,
)
.await;
let client = client_at(addr);
let resp = client.forum_categories("tok-123").await.unwrap();
server.await.unwrap();
let (method, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "GET");
assert_eq!(path, "/api/forum/categories");
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].slug, "recs");
assert_eq!(resp.items[0].topic_count, 3);
}
#[tokio::test]
async fn forum_topics_builds_query_and_auth() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"items":[],"next_cursor":null,"category":"recs","limit":10}"#,
)
.await;
let client = client_at(addr);
let resp = client
.forum_topics("tok-1", Some("fan fiction"), Some(42), 10)
.await
.unwrap();
server.await.unwrap();
let (method, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "GET");
assert!(path.starts_with("/api/forum/topics?"));
assert!(path.contains("limit=10"));
assert!(path.contains("category=fan%20fiction"));
assert!(path.contains("cursor=42"));
assert_eq!(resp.category, "recs");
}
#[tokio::test]
async fn forum_topic_detail_parses_posts_and_op_flag() {
let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
"author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
"body":"op text","payload":{},"view_count":12,"created_at":"2026-01-01T00:00:00Z",
"updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
"quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
"score":0,"is_op":true},{"id":101,"author_id":2,"author_username":"bob","body":"reply",
"quote_of":100,"quote":{"author_username":"alice","preview":"op"},"edited_at":null,
"deleted_at":null,"created_at":"2026-01-01T00:00:01Z","score":1,"is_op":false}],
"next_cursor":null,"limit":25,"view_count_before":11}"#;
let (server, addr, recorded) = serve_once(200, body).await;
let client = client_at(addr);
let resp = client.forum_topic("tok-9", 7, None).await.unwrap();
server.await.unwrap();
let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/topics/7");
assert_eq!(resp.title, "Hello");
assert_eq!(resp.items.len(), 2);
assert!(resp.items[0].is_op);
assert!(!resp.items[1].is_op);
assert_eq!(resp.items[1].quote.as_ref().unwrap().author_username, "alice");
}
#[tokio::test]
async fn forum_topic_by_slug_hits_slug_route() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
"author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
"body":"","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
"updated_at":null,"items":[],"next_cursor":null,"limit":25,"view_count_before":0}"#,
)
.await;
let client = client_at(addr);
let _ = client.forum_topic_by_slug("tok-2", "hello-7").await.unwrap();
server.await.unwrap();
let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/topics/by-slug/hello-7");
}
#[tokio::test]
async fn forum_create_topic_posts_json_and_parses() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"id":7,"post_id":100,"topic_slug":"hello-7","msg":"Topic created"}"#,
)
.await;
let client = client_at(addr);
let resp = client
.forum_create_topic("tok-3", "Hello", "meta", "body text", Some(serde_json::json!({"fic":"x"})))
.await
.unwrap();
server.await.unwrap();
let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/api/forum/topics");
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json["title"], "Hello");
assert_eq!(json["category_slug"], "meta");
assert_eq!(json["body"], "body text");
assert_eq!(json["payload"]["fic"], "x");
assert_eq!(resp.topic_slug.as_deref(), Some("hello-7"));
}
#[tokio::test]
async fn forum_reply_posts_quote_of_when_present() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"id":101,"msg":"Post created"}"#,
)
.await;
let client = client_at(addr);
let resp = client.forum_reply("tok-4", 7, "nice", Some(100)).await.unwrap();
server.await.unwrap();
let (_, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/topics/7/posts");
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json["body"], "nice");
assert_eq!(json["quote_of"], 100);
assert_eq!(resp.id, 101);
}
#[tokio::test]
async fn forum_follow_toggle_parses_state() {
let (server, addr, _) = serve_once(
200,
r#"{"err":0,"following":true,"follower_count":3}"#,
)
.await;
let client = client_at(addr);
let resp = client.forum_follow("tok-5", 7).await.unwrap();
server.await.unwrap();
assert!(resp.following);
assert_eq!(resp.follower_count, 3);
}
#[tokio::test]
async fn forum_mark_read_posts_body() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"last_read_post_id":100,"updated_at":"2026-01-01T00:00:00Z"}"#,
)
.await;
let client = client_at(addr);
let resp = client.forum_mark_read("tok-6", 7, Some(100)).await.unwrap();
server.await.unwrap();
let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/api/forum/topics/7/read");
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json["last_read_post_id"], 100);
assert_eq!(resp.last_read_post_id, 100);
}
#[tokio::test]
async fn forum_mark_read_defaults_when_none() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"last_read_post_id":50,"updated_at":"2026-01-01T00:00:00Z"}"#,
)
.await;
let client = client_at(addr);
let _ = client.forum_mark_read("tok-6", 7, None).await.unwrap();
server.await.unwrap();
let (_, _, body) = recorded.lock().unwrap().clone().unwrap();
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json, serde_json::json!({}));
}
#[tokio::test]
async fn forum_search_sends_q_and_category() {
let (server, addr, recorded) = serve_once(
200,
r#"{"err":0,"q":"drarry","results":[],"next_cursor":null,"limit":20,"total":0}"#,
)
.await;
let client = client_at(addr);
let resp = client.forum_search("tok-7", "dark harry", Some("recs")).await.unwrap();
server.await.unwrap();
let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
assert!(path.starts_with("/api/forum/search?"));
assert!(path.contains("q=dark%20harry"));
assert!(path.contains("category=recs"));
assert_eq!(resp.q, "drarry");
}
#[tokio::test]
async fn forum_moderation_and_metamod_endpoints() {
let (s1, a1, _) = serve_once(
200,
r#"{"err":0,"points_left":3,"expires_at":"2026-02-01T00:00:00Z","eligible":true}"#,
)
.await;
let c1 = client_at(a1);
let st = c1.forum_moderation_status("tok-8").await.unwrap();
s1.await.unwrap();
assert!(st.eligible);
assert_eq!(st.points_left, 3);
let (s2, a2, _) = serve_once(
200,
r#"{"err":0,"items":[{"post_id":5,"topic_id":1,"author_username":"bob","body":"x","score":-1,"mod_count":1,"reason":null,"created_at":"2026-01-01T00:00:00Z"}],"count":1,"pool_too_small":false}"#,
)
.await;
let c2 = client_at(a2);
let q = c2.forum_moderation_queue("tok-8").await.unwrap();
s2.await.unwrap();
assert_eq!(q.items.len(), 1);
assert_eq!(q.items[0].post_id, 5);
let (s3, a3, recorded) = serve_once(
200,
r#"{"err":0,"delta":-1,"score_after":-2,"hidden_until":null}"#,
)
.await;
let c3 = client_at(a3);
let m = c3.forum_moderate("tok-8", 5, "spam").await.unwrap();
s3.await.unwrap();
let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/api/forum/posts/5/moderate");
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json["reason"], "spam");
assert_eq!(m.delta, -1);
assert_eq!(m.score_after, -2);
let (s4, a4, _) = serve_once(
200,
r#"{"err":0,"items":[{"action_id":9,"post_id":5,"topic_id":1,"excerpt":"x","reason":"spam","delta":-1,"score_after":-2,"created_at":"2026-01-01T00:00:00Z"}],"count":1,"pool_too_small":false}"#,
)
.await;
let c4 = client_at(a4);
let mq = c4.forum_metamod_queue("tok-8").await.unwrap();
s4.await.unwrap();
assert_eq!(mq.items.len(), 1);
assert_eq!(mq.items[0].action_id, 9);
let (s5, a5, recorded) = serve_once(200, r#"{"err":0}"#).await;
let c5 = client_at(a5);
let v = c5.forum_metamod_vote("tok-8", 9, "fair").await.unwrap();
s5.await.unwrap();
let (_, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/metamod/9/vote");
let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
assert_eq!(json["verdict"], "fair");
assert_eq!(v.err, 0);
}
}