1use std::sync::Arc;
13
14use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
15use serde::de::DeserializeOwned;
16
17use crate::config::BotConfig;
18use crate::error::{BotError, Result};
19use crate::model::*;
20
21#[derive(Debug, Clone)]
23pub struct FichubClient {
24 http: reqwest::Client,
26 config: Arc<BotConfig>,
28}
29
30impl FichubClient {
31 pub fn new(config: Arc<BotConfig>) -> Result<Self> {
33 let http = reqwest::Client::builder()
34 .user_agent("fanfic-archivist/0.1 (FicHub Discord bot)")
35 .build()
36 .map_err(BotError::Http)?;
37 Ok(Self { http, config })
38 }
39
40 pub fn with_client(config: Arc<BotConfig>, http: reqwest::Client) -> Self {
42 Self { http, config }
43 }
44
45 pub fn http(&self) -> &reqwest::Client {
47 &self.http
48 }
49
50 async fn get<T: DeserializeOwned>(
52 &self,
53 path: &str,
54 auth_token: Option<&str>,
55 ) -> Result<T> {
56 let url = self.config.url(path);
57 let mut req = self.http.get(&url);
58 if let Some(tok) = auth_token {
59 req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
60 }
61 let resp = req.send().await?;
62 Self::parse_response(resp).await
63 }
64
65 async fn post<T: DeserializeOwned, B: serde::Serialize + ?Sized>(
67 &self,
68 path: &str,
69 body: &B,
70 auth_token: Option<&str>,
71 ) -> Result<T> {
72 let url = self.config.url(path);
73 let mut req = self.http.post(&url).header(CONTENT_TYPE, "application/json");
74 if let Some(tok) = auth_token {
75 req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
76 }
77 let resp = req.json(body).send().await?;
78 Self::parse_response(resp).await
79 }
80
81 async fn delete(
83 &self,
84 path: &str,
85 auth_token: Option<&str>,
86 ) -> Result<serde_json::Value> {
87 let url = self.config.url(path);
88 let mut req = self.http.delete(&url);
89 if let Some(tok) = auth_token {
90 req = req.header(AUTHORIZATION, format!("Bearer {tok}"));
91 }
92 let resp = req.send().await?;
93 Self::parse_response(resp).await
94 }
95
96 async fn parse_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
98 let status = resp.status().as_u16();
99 let body = resp.text().await.unwrap_or_default();
100 if status < 200 || status >= 300 {
101 return Err(BotError::Status { status, body });
102 }
103 let value: serde_json::Value = serde_json::from_str(&body)
106 .map_err(|e| BotError::Json(e))?;
107 if let Some(err) = value.get("err").and_then(|e| e.as_i64()) {
108 if err != 0 {
109 let msg = value
110 .get("msg")
111 .and_then(|m| m.as_str())
112 .unwrap_or("unknown error")
113 .to_string();
114 return Err(BotError::Api {
115 err: err as i32,
116 msg,
117 });
118 }
119 }
120 serde_json::from_value(value).map_err(BotError::Json)
121 }
122
123 pub async fn fetch_export(&self, url: &str) -> Result<ExportResponse> {
127 let q = urlencoding::encode(url);
128 self.get(&format!("/api/epub?q={q}"), None).await
129 }
130
131 pub async fn lazy_convert(&self, url: &str, format: &str) -> Result<ConvertResponse> {
133 let q = urlencoding::encode(url);
134 self.get(&format!("/api/epub/convert?q={q}&format={format}"), None)
135 .await
136 }
137
138 pub async fn fetch_meta(&self, url: &str) -> Result<ExportResponse> {
140 let q = urlencoding::encode(url);
141 self.get(&format!("/api/meta?q={q}"), None).await
142 }
143
144 pub async fn recommendations(&self, url: &str, n: i32) -> Result<RecommendationsResponse> {
148 let q = urlencoding::encode(url);
149 self.get(&format!("/api/recommendations?q={q}&n={n}"), None)
150 .await
151 }
152
153 pub async fn personal_recommendations(
155 &self,
156 token: &str,
157 ) -> Result<PersonalRecommendationsResponse> {
158 self.get("/api/recommendations/personal", Some(token)).await
159 }
160
161 pub async fn strategies(&self) -> Result<serde_json::Value> {
163 self.get("/api/recommendations/strategies", None).await
164 }
165
166 pub async fn search(
170 &self,
171 params: &SearchParams,
172 ) -> Result<SearchResponse> {
173 self.get(&format!("/api/search?{}", params.to_query()), None)
174 .await
175 }
176
177 pub async fn ask(&self, q: &str) -> Result<AskResponse> {
179 self.post("/api/search/ask", &serde_json::json!({ "q": q }), None)
180 .await
181 }
182
183 pub async fn body_search(
185 &self,
186 q: &str,
187 page: usize,
188 per_page: usize,
189 ) -> Result<BodySearchResponse> {
190 self.get(&format!("/api/search/body?q={q}&page={page}&per_page={per_page}"), None)
191 .await
192 }
193
194 pub async fn feed(&self, token: &str, page: i64) -> Result<FeedResponse> {
198 self.get(&format!("/api/v1/feed?page={page}"), Some(token)).await
199 }
200
201 pub async fn add_bookmark(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
203 self.post(
204 "/api/bookmarks",
205 &serde_json::json!({ "url_id": url_id }),
206 Some(token),
207 )
208 .await
209 }
210
211 pub async fn remove_bookmark(
213 &self,
214 token: &str,
215 work_id: i64,
216 ) -> Result<serde_json::Value> {
217 self.delete(&format!("/api/bookmarks/{work_id}"), Some(token)).await
218 }
219
220 pub async fn rate(&self, token: &str, url_id: &str, stars: i32) -> Result<serde_json::Value> {
222 self.post(
223 "/api/ratings",
224 &serde_json::json!({ "url_id": url_id, "stars": stars }),
225 Some(token),
226 )
227 .await
228 }
229
230 pub async fn add_block(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
232 self.post(
233 "/api/blocks",
234 &serde_json::json!({ "url_id": url_id }),
235 Some(token),
236 )
237 .await
238 }
239
240 pub async fn remove_block(&self, token: &str, url_id: &str) -> Result<serde_json::Value> {
242 self.delete(&format!("/api/blocks/{url_id}"), Some(token)).await
243 }
244
245 pub async fn kudos(&self, work_id: i64) -> Result<serde_json::Value> {
247 self.get(&format!("/api/kudos/{work_id}"), None).await
248 }
249
250 pub async fn list_requests(&self, status: &str, page: i64) -> Result<RequestsResponse> {
254 self.get(&format!("/api/requests?status={status}&page={page}"), None)
255 .await
256 }
257
258 pub async fn create_request(
260 &self,
261 token: &str,
262 title: &str,
263 body: &str,
264 ) -> Result<serde_json::Value> {
265 self.post(
266 "/api/requests",
267 &serde_json::json!({ "title": title, "body": body }),
268 Some(token),
269 )
270 .await
271 }
272
273 pub async fn consensus(&self) -> Result<ConsensusResponse> {
277 self.get("/api/roadmap/consensus", None).await
278 }
279
280 pub async fn login(&self, username: &str, password: &str) -> Result<AuthResponse> {
284 self.post(
285 "/api/auth/login",
286 &serde_json::json!({ "username": username, "password": password }),
287 None,
288 )
289 .await
290 }
291
292 pub async fn me(&self, token: &str) -> Result<MeResponse> {
294 self.get("/api/auth/me", Some(token)).await
295 }
296
297 pub async fn docs_ask(&self, q: &str, n: usize) -> Result<Vec<serde_json::Value>> {
300 let query = urlencoding::encode(q);
301 let value: serde_json::Value = self
302 .get(&format!("/api/docs/ask?q={query}&n={n}"), None)
303 .await?;
304 Ok(value
305 .get("sections")
306 .and_then(|v| v.as_array())
307 .cloned()
308 .unwrap_or_default())
309 }
310
311 pub async fn forum_categories(&self, token: &str) -> Result<ForumCategoriesResponse> {
318 self.get("/api/forum/categories", Some(token)).await
319 }
320
321 pub async fn forum_topics(
325 &self,
326 token: &str,
327 category: Option<&str>,
328 cursor: Option<i64>,
329 limit: u32,
330 ) -> Result<ForumTopicList> {
331 let mut path = format!("/api/forum/topics?limit={limit}");
332 if let Some(cat) = category {
333 path.push_str(&format!("&category={}", urlencoding::encode(cat)));
334 }
335 if let Some(c) = cursor {
336 path.push_str(&format!("&cursor={c}"));
337 }
338 self.get(&path, Some(token)).await
339 }
340
341 pub async fn forum_topic(
345 &self,
346 token: &str,
347 topic_id: i64,
348 after: Option<i64>,
349 ) -> Result<ForumTopicDetail> {
350 let path = match after {
351 Some(a) => format!("/api/forum/topics/{topic_id}?after={a}"),
352 None => format!("/api/forum/topics/{topic_id}"),
353 };
354 self.get(&path, Some(token)).await
355 }
356
357 pub async fn forum_topic_by_slug(&self, token: &str, slug: &str) -> Result<ForumTopicDetail> {
360 let s = urlencoding::encode(slug);
361 self.get(&format!("/api/forum/topics/by-slug/{s}"), Some(token))
362 .await
363 }
364
365 pub async fn forum_create_topic(
367 &self,
368 token: &str,
369 title: &str,
370 category_slug: &str,
371 body: &str,
372 payload: Option<serde_json::Value>,
373 ) -> Result<ForumCreateTopicResponse> {
374 let mut body_obj = serde_json::json!({
375 "title": title,
376 "category_slug": category_slug,
377 "body": body,
378 });
379 if let Some(p) = payload {
380 body_obj["payload"] = p;
381 }
382 self.post("/api/forum/topics", &body_obj, Some(token)).await
383 }
384
385 pub async fn forum_reply(
387 &self,
388 token: &str,
389 topic_id: i64,
390 body: &str,
391 quote_of: Option<i64>,
392 ) -> Result<ForumPostCreated> {
393 let mut body_obj = serde_json::json!({ "body": body });
394 if let Some(q) = quote_of {
395 body_obj["quote_of"] = serde_json::json!(q);
396 }
397 self.post(
398 &format!("/api/forum/topics/{topic_id}/posts"),
399 &body_obj,
400 Some(token),
401 )
402 .await
403 }
404
405 pub async fn forum_follow(&self, token: &str, topic_id: i64) -> Result<ForumFollowState> {
409 self.post(
410 &format!("/api/forum/topics/{topic_id}/follow"),
411 &serde_json::json!({}),
412 Some(token),
413 )
414 .await
415 }
416
417 pub async fn forum_follow_state(&self, token: &str, topic_id: i64) -> Result<ForumFollowState> {
419 self.get(&format!("/api/forum/topics/{topic_id}/follow"), Some(token))
420 .await
421 }
422
423 pub async fn forum_mark_read(
426 &self,
427 token: &str,
428 topic_id: i64,
429 last_read_post_id: Option<i64>,
430 ) -> Result<ForumMarkRead> {
431 let body = match last_read_post_id {
432 Some(n) => serde_json::json!({ "last_read_post_id": n }),
433 None => serde_json::json!({}),
434 };
435 self.post(
436 &format!("/api/forum/topics/{topic_id}/read"),
437 &body,
438 Some(token),
439 )
440 .await
441 }
442
443 pub async fn forum_search(
447 &self,
448 token: &str,
449 q: &str,
450 category: Option<&str>,
451 ) -> Result<ForumSearchResponse> {
452 let mut path = format!("/api/forum/search?q={}", urlencoding::encode(q));
453 if let Some(cat) = category {
454 path.push_str(&format!("&category={}", urlencoding::encode(cat)));
455 }
456 self.get(&path, Some(token)).await
457 }
458
459 pub async fn forum_moderation_status(&self, token: &str) -> Result<ForumModerationStatus> {
463 self.get("/api/forum/moderation/status", Some(token)).await
464 }
465
466 pub async fn forum_moderation_queue(&self, token: &str) -> Result<ForumModerationQueue> {
468 self.get("/api/forum/moderation/queue", Some(token)).await
469 }
470
471 pub async fn forum_moderate(
473 &self,
474 token: &str,
475 post_id: i64,
476 reason: &str,
477 ) -> Result<ForumModerateResponse> {
478 self.post(
479 &format!("/api/forum/posts/{post_id}/moderate"),
480 &serde_json::json!({ "reason": reason }),
481 Some(token),
482 )
483 .await
484 }
485
486 pub async fn forum_metamod_queue(&self, token: &str) -> Result<ForumMetamodQueue> {
488 self.get("/api/forum/metamod/queue", Some(token)).await
489 }
490
491 pub async fn forum_metamod_vote(
494 &self,
495 token: &str,
496 action_id: i64,
497 verdict: &str,
498 ) -> Result<ForumMetamodVoteResponse> {
499 self.post(
500 &format!("/api/forum/metamod/{action_id}/vote"),
501 &serde_json::json!({ "verdict": verdict }),
502 Some(token),
503 )
504 .await
505 }
506}
507
508#[derive(Debug, Clone, Default)]
510pub struct SearchParams {
511 pub q: String,
513 pub page: Option<usize>,
515 pub per_page: Option<usize>,
517 pub min_words: Option<i64>,
519 pub max_words: Option<i64>,
521 pub complete: Option<bool>,
523 pub source: Option<String>,
525 pub fandom: Option<String>,
527 pub exclude_fandom: Option<String>,
529 pub main_char_attr: Option<String>,
531 pub min_kudos: Option<i64>,
533 pub sort: Option<String>,
535 pub include_tags: Option<String>,
537 pub exclude_tags: Option<String>,
539}
540
541impl SearchParams {
542 pub fn to_query(&self) -> String {
544 let mut qs = Vec::new();
545 qs.push(format!("q={}", urlencoding::encode(&self.q)));
546 if let Some(p) = self.page {
547 qs.push(format!("page={p}"));
548 }
549 if let Some(p) = self.per_page {
550 qs.push(format!("per_page={p}"));
551 }
552 if let Some(v) = self.min_words {
553 qs.push(format!("min_words={v}"));
554 }
555 if let Some(v) = self.max_words {
556 qs.push(format!("max_words={v}"));
557 }
558 if let Some(v) = self.complete {
559 qs.push(format!("complete={v}"));
560 }
561 if let Some(v) = &self.source {
562 qs.push(format!("source={}", urlencoding::encode(v)));
563 }
564 if let Some(v) = &self.fandom {
565 qs.push(format!("fandom={}", urlencoding::encode(v)));
566 }
567 if let Some(v) = &self.exclude_fandom {
568 qs.push(format!("exclude_fandom={}", urlencoding::encode(v)));
569 }
570 if let Some(v) = &self.main_char_attr {
571 qs.push(format!("main_char_attr={}", urlencoding::encode(v)));
572 }
573 if let Some(v) = self.min_kudos {
574 qs.push(format!("min_kudos={v}"));
575 }
576 if let Some(v) = &self.sort {
577 qs.push(format!("sort={}", urlencoding::encode(v)));
578 }
579 if let Some(v) = &self.include_tags {
580 qs.push(format!("include_tags={}", urlencoding::encode(v)));
581 }
582 if let Some(v) = &self.exclude_tags {
583 qs.push(format!("exclude_tags={}", urlencoding::encode(v)));
584 }
585 qs.join("&")
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use tokio::io::{AsyncReadExt, AsyncWriteExt};
593
594 #[test]
595 fn search_params_query_builds() {
596 let p = SearchParams {
597 q: "harry potter".into(),
598 min_words: Some(50000),
599 complete: Some(true),
600 sort: Some("kudos".into()),
601 ..Default::default()
602 };
603 let q = p.to_query();
604 assert!(q.contains("q=harry%20potter"));
605 assert!(q.contains("min_words=50000"));
606 assert!(q.contains("complete=true"));
607 assert!(q.contains("sort=kudos"));
608 }
609
610 #[test]
611 fn search_params_omits_none() {
612 let p = SearchParams {
613 q: "drarry".into(),
614 ..Default::default()
615 };
616 let q = p.to_query();
617 assert_eq!(q, "q=drarry");
618 assert!(!q.contains("page="));
619 assert!(!q.contains("min_words="));
620 }
621
622 #[test]
623 fn search_params_urlencodes_values() {
624 let p = SearchParams {
625 q: "dark harry".into(),
626 main_char_attr: Some("Harry Potter|Dark Harry".into()),
627 ..Default::default()
628 };
629 let q = p.to_query();
630 assert!(q.contains("main_char_attr=Harry%20Potter%7CDark%20Harry"));
631 }
632
633 #[tokio::test]
637 async fn search_hits_api_search_endpoint() {
638 use std::sync::Arc;
639 use tokio::io::{AsyncReadExt, AsyncWriteExt};
640
641 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
644 let addr = listener.local_addr().unwrap();
645 let requested_path = Arc::new(std::sync::Mutex::new(String::new()));
646 let path_clone = Arc::clone(&requested_path);
647 let server_path = Arc::clone(&requested_path);
648
649 let server = tokio::spawn(async move {
650 let (mut sock, _) = listener.accept().await.unwrap();
651 let mut buf = [0u8; 4096];
652 let n = sock.read(&mut buf).await.unwrap();
653 let req = String::from_utf8_lossy(&buf[..n]);
654 let path = req.split_whitespace().nth(1).unwrap_or("").to_string();
655 *server_path.lock().unwrap() = path;
656 let body = r#"{"total":0,"results":[],"page":1,"per_page":20,"facets":{}}"#;
657 let resp = format!(
658 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
659 body.len(),
660 body
661 );
662 let _ = sock.write_all(resp.as_bytes()).await;
663 });
664
665 let mut cfg = crate::config::BotConfig::default();
667 cfg.base_url = format!("http://{addr}");
668 let http = reqwest::Client::builder().build().unwrap();
669 let client = FichubClient::with_client(Arc::new(cfg), http);
670
671 let params = SearchParams { q: "jillian".into(), ..Default::default() };
672 let _ = client.search(¶ms).await;
673
674 server.await.unwrap();
675 let path = path_clone.lock().unwrap().clone();
676 assert!(
677 path.starts_with("/api/search?"),
678 "search() must request /api/search?..., got: {path}"
679 );
680 assert!(path.contains("q=jillian"), "path should carry q param, got: {path}");
681 }
682
683 async fn serve_once(
693 status: u16,
694 body: &'static str,
695 ) -> (
696 tokio::task::JoinHandle<()>,
697 std::net::SocketAddr,
698 Arc<std::sync::Mutex<Option<(String, String, Option<String>)>>>,
699 ) {
700 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
701 let addr = listener.local_addr().unwrap();
702 let recorded = Arc::new(std::sync::Mutex::new(None));
703 let rec = Arc::clone(&recorded);
704 let resp_len = body.len();
705 let handle = tokio::spawn(async move {
706 let (mut sock, _) = listener.accept().await.unwrap();
707 let mut buf = [0u8; 8192];
708 let n = sock.read(&mut buf).await.unwrap();
709 let req = String::from_utf8_lossy(&buf[..n]).to_string();
710 let mut lines = req.lines();
711 let request_line = lines.next().unwrap_or("").to_string();
712 let mut parts = request_line.split_whitespace();
713 let method = parts.next().unwrap_or("").to_string();
714 let path = parts.next().unwrap_or("").to_string();
715 let body_str = req
717 .split_once("\r\n\r\n")
718 .map(|(_, b)| b.to_string())
719 .unwrap_or_default();
720 let req_body = if body_str.is_empty() { None } else { Some(body_str) };
721 *rec.lock().unwrap() = Some((method, path, req_body));
722 let resp = format!(
723 "HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {resp_len}\r\nconnection: close\r\n\r\n{body}"
724 );
725 let _ = sock.write_all(resp.as_bytes()).await;
726 });
727 (handle, addr, recorded)
728 }
729
730 fn client_at(addr: std::net::SocketAddr) -> FichubClient {
733 let mut cfg = crate::config::BotConfig::default();
734 cfg.base_url = format!("http://{addr}");
735 let http = reqwest::Client::builder().build().unwrap();
736 FichubClient::with_client(Arc::new(cfg), http)
737 }
738
739 #[tokio::test]
740 async fn forum_categories_sends_bearer_and_parses() {
741 let (server, addr, recorded) = serve_once(
742 200,
743 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}]}"#,
744 )
745 .await;
746 let client = client_at(addr);
747 let resp = client.forum_categories("tok-123").await.unwrap();
748 server.await.unwrap();
749 let (method, path, _) = recorded.lock().unwrap().clone().unwrap();
750 assert_eq!(method, "GET");
751 assert_eq!(path, "/api/forum/categories");
752 assert_eq!(resp.items.len(), 1);
753 assert_eq!(resp.items[0].slug, "recs");
754 assert_eq!(resp.items[0].topic_count, 3);
755 }
756
757 #[tokio::test]
758 async fn forum_topics_builds_query_and_auth() {
759 let (server, addr, recorded) = serve_once(
760 200,
761 r#"{"err":0,"items":[],"next_cursor":null,"category":"recs","limit":10}"#,
762 )
763 .await;
764 let client = client_at(addr);
765 let resp = client
766 .forum_topics("tok-1", Some("fan fiction"), Some(42), 10)
767 .await
768 .unwrap();
769 server.await.unwrap();
770 let (method, path, _) = recorded.lock().unwrap().clone().unwrap();
771 assert_eq!(method, "GET");
772 assert!(path.starts_with("/api/forum/topics?"));
773 assert!(path.contains("limit=10"));
774 assert!(path.contains("category=fan%20fiction"));
775 assert!(path.contains("cursor=42"));
776 assert_eq!(resp.category, "recs");
777 }
778
779 #[tokio::test]
780 async fn forum_topic_detail_parses_posts_and_op_flag() {
781 let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
782 "author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
783 "body":"op text","payload":{},"view_count":12,"created_at":"2026-01-01T00:00:00Z",
784 "updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
785 "quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
786 "score":0,"is_op":true},{"id":101,"author_id":2,"author_username":"bob","body":"reply",
787 "quote_of":100,"quote":{"author_username":"alice","preview":"op"},"edited_at":null,
788 "deleted_at":null,"created_at":"2026-01-01T00:00:01Z","score":1,"is_op":false}],
789 "next_cursor":null,"limit":25,"view_count_before":11}"#;
790 let (server, addr, recorded) = serve_once(200, body).await;
791 let client = client_at(addr);
792 let resp = client.forum_topic("tok-9", 7, None).await.unwrap();
793 server.await.unwrap();
794 let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
795 assert_eq!(path, "/api/forum/topics/7");
796 assert_eq!(resp.title, "Hello");
797 assert_eq!(resp.items.len(), 2);
798 assert!(resp.items[0].is_op);
799 assert!(!resp.items[1].is_op);
800 assert_eq!(resp.items[1].quote.as_ref().unwrap().author_username, "alice");
801 }
802
803 #[tokio::test]
804 async fn forum_topic_by_slug_hits_slug_route() {
805 let (server, addr, recorded) = serve_once(
806 200,
807 r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
808 "author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
809 "body":"","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
810 "updated_at":null,"items":[],"next_cursor":null,"limit":25,"view_count_before":0}"#,
811 )
812 .await;
813 let client = client_at(addr);
814 let _ = client.forum_topic_by_slug("tok-2", "hello-7").await.unwrap();
815 server.await.unwrap();
816 let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
817 assert_eq!(path, "/api/forum/topics/by-slug/hello-7");
818 }
819
820 #[tokio::test]
821 async fn forum_create_topic_posts_json_and_parses() {
822 let (server, addr, recorded) = serve_once(
823 200,
824 r#"{"err":0,"id":7,"post_id":100,"topic_slug":"hello-7","msg":"Topic created"}"#,
825 )
826 .await;
827 let client = client_at(addr);
828 let resp = client
829 .forum_create_topic("tok-3", "Hello", "meta", "body text", Some(serde_json::json!({"fic":"x"})))
830 .await
831 .unwrap();
832 server.await.unwrap();
833 let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
834 assert_eq!(method, "POST");
835 assert_eq!(path, "/api/forum/topics");
836 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
837 assert_eq!(json["title"], "Hello");
838 assert_eq!(json["category_slug"], "meta");
839 assert_eq!(json["body"], "body text");
840 assert_eq!(json["payload"]["fic"], "x");
841 assert_eq!(resp.topic_slug.as_deref(), Some("hello-7"));
842 }
843
844 #[tokio::test]
845 async fn forum_reply_posts_quote_of_when_present() {
846 let (server, addr, recorded) = serve_once(
847 200,
848 r#"{"err":0,"id":101,"msg":"Post created"}"#,
849 )
850 .await;
851 let client = client_at(addr);
852 let resp = client.forum_reply("tok-4", 7, "nice", Some(100)).await.unwrap();
853 server.await.unwrap();
854 let (_, path, body) = recorded.lock().unwrap().clone().unwrap();
855 assert_eq!(path, "/api/forum/topics/7/posts");
856 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
857 assert_eq!(json["body"], "nice");
858 assert_eq!(json["quote_of"], 100);
859 assert_eq!(resp.id, 101);
860 }
861
862 #[tokio::test]
863 async fn forum_follow_toggle_parses_state() {
864 let (server, addr, _) = serve_once(
865 200,
866 r#"{"err":0,"following":true,"follower_count":3}"#,
867 )
868 .await;
869 let client = client_at(addr);
870 let resp = client.forum_follow("tok-5", 7).await.unwrap();
871 server.await.unwrap();
872 assert!(resp.following);
873 assert_eq!(resp.follower_count, 3);
874 }
875
876 #[tokio::test]
877 async fn forum_mark_read_posts_body() {
878 let (server, addr, recorded) = serve_once(
879 200,
880 r#"{"err":0,"last_read_post_id":100,"updated_at":"2026-01-01T00:00:00Z"}"#,
881 )
882 .await;
883 let client = client_at(addr);
884 let resp = client.forum_mark_read("tok-6", 7, Some(100)).await.unwrap();
885 server.await.unwrap();
886 let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
887 assert_eq!(method, "POST");
888 assert_eq!(path, "/api/forum/topics/7/read");
889 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
890 assert_eq!(json["last_read_post_id"], 100);
891 assert_eq!(resp.last_read_post_id, 100);
892 }
893
894 #[tokio::test]
895 async fn forum_mark_read_defaults_when_none() {
896 let (server, addr, recorded) = serve_once(
897 200,
898 r#"{"err":0,"last_read_post_id":50,"updated_at":"2026-01-01T00:00:00Z"}"#,
899 )
900 .await;
901 let client = client_at(addr);
902 let _ = client.forum_mark_read("tok-6", 7, None).await.unwrap();
903 server.await.unwrap();
904 let (_, _, body) = recorded.lock().unwrap().clone().unwrap();
905 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
906 assert_eq!(json, serde_json::json!({}));
907 }
908
909 #[tokio::test]
910 async fn forum_search_sends_q_and_category() {
911 let (server, addr, recorded) = serve_once(
912 200,
913 r#"{"err":0,"q":"drarry","results":[],"next_cursor":null,"limit":20,"total":0}"#,
914 )
915 .await;
916 let client = client_at(addr);
917 let resp = client.forum_search("tok-7", "dark harry", Some("recs")).await.unwrap();
918 server.await.unwrap();
919 let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
920 assert!(path.starts_with("/api/forum/search?"));
921 assert!(path.contains("q=dark%20harry"));
922 assert!(path.contains("category=recs"));
923 assert_eq!(resp.q, "drarry");
924 }
925
926 #[tokio::test]
927 async fn forum_moderation_and_metamod_endpoints() {
928 let (s1, a1, _) = serve_once(
930 200,
931 r#"{"err":0,"points_left":3,"expires_at":"2026-02-01T00:00:00Z","eligible":true}"#,
932 )
933 .await;
934 let c1 = client_at(a1);
935 let st = c1.forum_moderation_status("tok-8").await.unwrap();
936 s1.await.unwrap();
937 assert!(st.eligible);
938 assert_eq!(st.points_left, 3);
939
940 let (s2, a2, _) = serve_once(
942 200,
943 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}"#,
944 )
945 .await;
946 let c2 = client_at(a2);
947 let q = c2.forum_moderation_queue("tok-8").await.unwrap();
948 s2.await.unwrap();
949 assert_eq!(q.items.len(), 1);
950 assert_eq!(q.items[0].post_id, 5);
951
952 let (s3, a3, recorded) = serve_once(
954 200,
955 r#"{"err":0,"delta":-1,"score_after":-2,"hidden_until":null}"#,
956 )
957 .await;
958 let c3 = client_at(a3);
959 let m = c3.forum_moderate("tok-8", 5, "spam").await.unwrap();
960 s3.await.unwrap();
961 let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
962 assert_eq!(method, "POST");
963 assert_eq!(path, "/api/forum/posts/5/moderate");
964 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
965 assert_eq!(json["reason"], "spam");
966 assert_eq!(m.delta, -1);
967 assert_eq!(m.score_after, -2);
968
969 let (s4, a4, _) = serve_once(
971 200,
972 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}"#,
973 )
974 .await;
975 let c4 = client_at(a4);
976 let mq = c4.forum_metamod_queue("tok-8").await.unwrap();
977 s4.await.unwrap();
978 assert_eq!(mq.items.len(), 1);
979 assert_eq!(mq.items[0].action_id, 9);
980
981 let (s5, a5, recorded) = serve_once(200, r#"{"err":0}"#).await;
983 let c5 = client_at(a5);
984 let v = c5.forum_metamod_vote("tok-8", 9, "fair").await.unwrap();
985 s5.await.unwrap();
986 let (_, path, body) = recorded.lock().unwrap().clone().unwrap();
987 assert_eq!(path, "/api/forum/metamod/9/vote");
988 let json: serde_json::Value = serde_json::from_str(&body.unwrap()).unwrap();
989 assert_eq!(json["verdict"], "fair");
990 assert_eq!(v.err, 0);
991 }
992}