Skip to main content

archivist_core/
api.rs

1//! Typed HTTP client for the FicHub REST API.
2//!
3//! Wraps `reqwest` and every endpoint the bot uses. Never touches the FicHub
4//! database directly — this is the shared contract between the bot and the
5//! archive (mirrors what the frontend's `api/client.ts` does).
6//!
7//! Auth: endpoints that need a logged-in user (`/api/recommendations/personal`,
8//! `/api/v1/feed`, `/api/bookmarks`, `/api/ratings`, `/api/kudos`, `/api/blocks`,
9//! `/api/requests` voting, `/api/roadmap` voting) take an `auth_token` and send
10//! it as `Authorization: Bearer <token>`.
11
12use 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/// A client for the FicHub REST API.
22#[derive(Debug, Clone)]
23pub struct FichubClient {
24    /// Shared reqwest client (cookies + rustls).
25    http: reqwest::Client,
26    /// Bot config (base URL).
27    config: Arc<BotConfig>,
28}
29
30impl FichubClient {
31    /// Build a new client from a config.
32    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    /// Build a client with a custom `reqwest::Client` (used in tests with a mock).
41    pub fn with_client(config: Arc<BotConfig>, http: reqwest::Client) -> Self {
42        Self { http, config }
43    }
44
45    /// Access the underlying `reqwest::Client` (shared with intent/LLM callers).
46    pub fn http(&self) -> &reqwest::Client {
47        &self.http
48    }
49
50    /// Low-level GET returning deserialized JSON.
51    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    /// Low-level POST with a JSON body returning deserialized JSON.
66    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    /// Low-level DELETE returning unit (used for blocklist removal).
82    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    /// Parse a response into the typed shape, checking status + `err` field.
97    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        // Most FicHub endpoints embed `err` (0 = ok). Deserialize first, then
104        // check err field if present.
105        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    // ── Export / metadata ──────────────────────────────────────────────
124
125    /// `GET /api/epub?q=<url>` — export metadata + download URLs.
126    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    /// `GET /api/epub/convert?q=<url>&format=mobi|pdf|azw3` — lazy conversion.
132    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    /// `GET /api/meta?q=<url>` — metadata only (no download URLs).
139    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    // ── Recommendations ────────────────────────────────────────────────
145
146    /// `GET /api/recommendations?q=&n=` — similar works to a seed URL.
147    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    /// `GET /api/recommendations/personal` — personalized recs (auth).
154    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    /// `GET /api/recommendations/strategies` — list available strategies.
162    pub async fn strategies(&self) -> Result<serde_json::Value> {
163        self.get("/api/recommendations/strategies", None).await
164    }
165
166    // ── Search ─────────────────────────────────────────────────────────
167
168    /// `GET /api/search?q=&page=&per_page=` — advanced search.
169    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    /// `POST /api/search/ask` — LLM-assisted natural-language search.
178    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    /// `GET /api/search/body?q=&page=&per_page=` — full-text body search.
184    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    // ── Social / library (auth) ────────────────────────────────────────
195
196    /// `GET /api/v1/feed?page=` — new chapters from followed works (auth).
197    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    /// `POST /api/bookmarks` — bookmark a fic (auth).
202    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    /// `DELETE /api/bookmarks/{work_id}` — remove a bookmark (auth).
212    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    /// `POST /api/ratings` — rate a fic (auth).
221    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    /// `POST /api/blocks` — block/hide a fic from recommendations (auth).
231    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    /// `DELETE /api/blocks/{url_id}` — unblock a fic (auth).
241    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    /// `GET /api/kudos/{work_id}` — kudos counts for a work.
246    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    // ── Community: Fic Requests ────────────────────────────────────────
251
252    /// `GET /api/requests?status=&page=` — list requests.
253    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    /// `POST /api/requests` — create a request (auth).
259    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    // ── Community: Roadmap consensus ───────────────────────────────────
274
275    /// `GET /api/roadmap/consensus` — public leaderboard + controversy.
276    pub async fn consensus(&self) -> Result<ConsensusResponse> {
277        self.get("/api/roadmap/consensus", None).await
278    }
279
280    // ── Auth ───────────────────────────────────────────────────────────
281
282    /// `POST /api/auth/login` — exchange username/password for a JWT.
283    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    /// `GET /api/auth/me` — verify a token, get the current user.
293    pub async fn me(&self, token: &str) -> Result<MeResponse> {
294        self.get("/api/auth/me", Some(token)).await
295    }
296
297    /// `GET /api/docs/ask?q=...&n=...` — Ask-the-Docs RAG (grounded help).
298    /// Returns the top matching doc sections (with anchors) as JSON values.
299    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    // ── Forum: categories / topics / posts (F2/F3) ────────────────────────
312    //
313    // All forum endpoints are auth-gated. Path shapes follow
314    // FORUM-API-CONTRACT.md; response shapes mirror `src/routes/forum.rs`.
315
316    /// `GET /api/forum/categories` — visible categories + topic counts (auth).
317    pub async fn forum_categories(&self, token: &str) -> Result<ForumCategoriesResponse> {
318        self.get("/api/forum/categories", Some(token)).await
319    }
320
321    /// `GET /api/forum/topics?category=&cursor=&limit=` — cursor-paginated
322    /// topic list for a category (auth). `cursor` is the last topic id seen
323    /// (from `next_cursor`); omit for the first page.
324    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    /// `GET /api/forum/topics/{id}?after=` — topic detail + posts (auth).
342    /// `after` is the last post id seen (from `next_cursor`); omit for the
343    /// first page (OP + newest posts, default limit 25).
344    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    /// `GET /api/forum/topics/by-slug/{slug}` — topic detail resolved by the
358    /// canonical slug (same shape as `forum_topic`, auth).
359    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    /// `POST /api/forum/topics` — create a topic + OP post (auth).
366    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    /// `POST /api/forum/topics/{id}/posts` — reply to a topic (auth).
386    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    // ── Forum: follow / read state (F3/F4) ────────────────────────────────
406
407    /// `POST /api/forum/topics/{id}/follow` — toggle follow (auth).
408    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    /// `GET /api/forum/topics/{id}/follow` — my follow state + follower count (auth).
418    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    /// `POST /api/forum/topics/{id}/read` — mark a topic read (auth).
424    /// `last_read_post_id` defaults to the topic's newest post when `None`.
425    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    // ── Forum: search (F4) ────────────────────────────────────────────────
444
445    /// `GET /api/forum/search?q=&category=` — FTS over topics + posts (auth).
446    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    // ── Forum: moderation (F5) / metamoderation (F6) ──────────────────────
460
461    /// `GET /api/forum/moderation/status` — my points + eligibility (auth).
462    pub async fn forum_moderation_status(&self, token: &str) -> Result<ForumModerationStatus> {
463        self.get("/api/forum/moderation/status", Some(token)).await
464    }
465
466    /// `GET /api/forum/moderation/queue` — posts needing moderation (auth).
467    pub async fn forum_moderation_queue(&self, token: &str) -> Result<ForumModerationQueue> {
468        self.get("/api/forum/moderation/queue", Some(token)).await
469    }
470
471    /// `POST /api/forum/posts/{id}/moderate` — spend 1 point on a post (auth).
472    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    /// `GET /api/forum/metamod/queue` — anonymized actions to rate (auth).
487    pub async fn forum_metamod_queue(&self, token: &str) -> Result<ForumMetamodQueue> {
488        self.get("/api/forum/metamod/queue", Some(token)).await
489    }
490
491    /// `POST /api/forum/metamod/{action_id}/vote` — rate a mod action (auth).
492    /// `verdict` is one of `fair` | `unfair` | `unsure`.
493    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/// Query parameter builder for advanced search.
509#[derive(Debug, Clone, Default)]
510pub struct SearchParams {
511    /// Full-text query string.
512    pub q: String,
513    /// Page number (1-based).
514    pub page: Option<usize>,
515    /// Results per page.
516    pub per_page: Option<usize>,
517    /// Minimum word count filter.
518    pub min_words: Option<i64>,
519    /// Maximum word count filter.
520    pub max_words: Option<i64>,
521    /// Only return completed works when `true`.
522    pub complete: Option<bool>,
523    /// Source-site filter (e.g. `archiveofourown.org`).
524    pub source: Option<String>,
525    /// Fandom filter (comma-separated fandoms accepted).
526    pub fandom: Option<String>,
527    /// Exclude these fandoms (comma-separated).
528    pub exclude_fandom: Option<String>,
529    /// Main character/relationship attribute filter.
530    pub main_char_attr: Option<String>,
531    /// Minimum kudos filter.
532    pub min_kudos: Option<i64>,
533    /// Sort key (e.g. `kudos`, `updated`, `words`).
534    pub sort: Option<String>,
535    /// Require these tags (comma-separated).
536    pub include_tags: Option<String>,
537    /// Exclude these tags (comma-separated).
538    pub exclude_tags: Option<String>,
539}
540
541impl SearchParams {
542    /// Render as a query string for `GET /api/search`.
543    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    /// Regression: `/search` must request the real `/api/search` endpoint, not
634    /// a bare query string (which hits the SPA fallback and returns HTML,
635    /// failing JSON parse: "expected value at line 1 column 1").
636    #[tokio::test]
637    async fn search_hits_api_search_endpoint() {
638        use std::sync::Arc;
639        use tokio::io::{AsyncReadExt, AsyncWriteExt};
640
641        // Echo server: record the request path, respond with a valid
642        // SearchResponse JSON.
643        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        // Point the client at the echo server.
666        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(&params).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    // ── Forum endpoint tests ────────────────────────────────────────────
684    //
685    // Each test runs a tiny tokio echo server (no extra dev-deps) that
686    // records the request line + body and replies with the documented forum
687    // JSON shape. Mirrors the existing `search_hits_api_search_endpoint`
688    // pattern.
689
690    /// Accept one HTTP request, record (method, path, optional body),
691    /// respond with `status` + `body`. Returns the recorded request.
692    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            // Split headers from body (headers end at the blank line).
716            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    /// Build a client pointed at the echo server (BotConfig::default reads
731    /// env; the explicit base_url override wins).
732    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        // status
929        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        // queue
941        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        // moderate
953        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        // metamod queue
970        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        // metamod vote
982        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}