Skip to main content

ghost_io_api/client/
content.rs

1//! Ghost Content API client.
2//!
3//! Provides read-only access to published Ghost content: posts, pages, tags,
4//! authors, tiers, and site settings.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use ghost_io_api::auth::content::ContentApiKey;
10//! use ghost_io_api::client::content::{GhostContentClient, BrowsePostsParams};
11//!
12//! # async fn example() -> ghost_io_api::error::Result<()> {
13//! let key = ContentApiKey::new("22444f78447824223cefc48062")?;
14//! let client = GhostContentClient::new("https://demo.ghost.io", key)?;
15//!
16//! let posts = client.browse_posts(BrowsePostsParams::default()).await?;
17//! println!("Found {} posts", posts.posts.len());
18//! # Ok(())
19//! # }
20//! ```
21
22use crate::auth::content::ContentApiKey;
23use crate::error::{GhostError, Result};
24use crate::models::author::Author;
25use crate::models::page::Page;
26use crate::models::pagination::Meta;
27use crate::models::post::Post;
28use crate::models::settings::Settings;
29use crate::models::tag::Tag;
30use reqwest::{header, Client};
31use serde::{Deserialize, Serialize};
32
33const GHOST_API_VERSION: &str = "v5.0";
34const CONTENT_API_PATH: &str = "/ghost/api/content";
35
36// ── Query parameter helpers ──────────────────────────────────────────────────
37
38/// Parameters for browsing posts.
39///
40/// All fields are optional. When not specified, Ghost applies its defaults
41/// (page 1, limit 15, published posts only).
42#[derive(Debug, Clone, Default)]
43pub struct BrowsePostsParams {
44    /// Page number to retrieve (1-indexed). Defaults to 1.
45    pub page: Option<u32>,
46    /// Number of posts per page. Defaults to 15.
47    pub limit: Option<u32>,
48    /// Relations to include, e.g. `"authors,tags"`.
49    pub include: Option<String>,
50    /// Fields to return, e.g. `"id,title,slug"`.
51    pub fields: Option<String>,
52    /// Filter expression, e.g. `"featured:true"`.
53    pub filter: Option<String>,
54    /// Sort order, e.g. `"published_at DESC"`.
55    pub order: Option<String>,
56}
57
58impl BrowsePostsParams {
59    fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
60        let mut pairs = Vec::new();
61        if let Some(page) = self.page {
62            pairs.push(("page", page.to_string()));
63        }
64        if let Some(limit) = self.limit {
65            pairs.push(("limit", limit.to_string()));
66        }
67        if let Some(ref include) = self.include {
68            pairs.push(("include", include.clone()));
69        }
70        if let Some(ref fields) = self.fields {
71            pairs.push(("fields", fields.clone()));
72        }
73        if let Some(ref filter) = self.filter {
74            pairs.push(("filter", filter.clone()));
75        }
76        if let Some(ref order) = self.order {
77            pairs.push(("order", order.clone()));
78        }
79        pairs
80    }
81}
82
83/// Parameters for browsing a collection (pages, tags, authors, tiers).
84///
85/// Equivalent to [`BrowsePostsParams`] but used for non-post endpoints.
86pub type BrowsePagesParams = BrowsePostsParams;
87/// Parameters for browsing tags.
88pub type BrowseTagsParams = BrowsePostsParams;
89/// Parameters for browsing authors.
90pub type BrowseAuthorsParams = BrowsePostsParams;
91/// Parameters for browsing tiers.
92pub type BrowseTiersParams = BrowsePostsParams;
93
94// ── Response envelopes ───────────────────────────────────────────────────────
95
96/// Response envelope for browse-posts requests.
97#[derive(Debug, Deserialize)]
98pub struct PostsResponse {
99    /// Posts returned by the API.
100    pub posts: Vec<Post>,
101    /// Pagination metadata.
102    pub meta: Meta,
103}
104
105/// Response envelope for single-post read requests.
106#[derive(Debug, Deserialize)]
107pub struct PostResponse {
108    /// Single-element vec containing the post.
109    pub posts: Vec<Post>,
110}
111
112/// Response envelope for browse-pages requests.
113#[derive(Debug, Deserialize)]
114pub struct PagesResponse {
115    /// Pages returned by the API.
116    pub pages: Vec<Page>,
117    /// Pagination metadata.
118    pub meta: Meta,
119}
120
121/// Response envelope for single-page read requests.
122#[derive(Debug, Deserialize)]
123pub struct PageResponse {
124    /// Single-element vec containing the page.
125    pub pages: Vec<Page>,
126}
127
128/// Response envelope for browse-tags requests.
129#[derive(Debug, Deserialize)]
130pub struct TagsResponse {
131    /// Tags returned by the API.
132    pub tags: Vec<Tag>,
133    /// Pagination metadata.
134    pub meta: Meta,
135}
136
137/// Response envelope for single-tag read requests.
138#[derive(Debug, Deserialize)]
139pub struct TagResponse {
140    /// Single-element vec containing the tag.
141    pub tags: Vec<Tag>,
142}
143
144/// Response envelope for browse-authors requests.
145#[derive(Debug, Deserialize)]
146pub struct AuthorsResponse {
147    /// Authors returned by the API.
148    pub authors: Vec<Author>,
149    /// Pagination metadata.
150    pub meta: Meta,
151}
152
153/// Response envelope for single-author read requests.
154#[derive(Debug, Deserialize)]
155pub struct AuthorResponse {
156    /// Single-element vec containing the author.
157    pub authors: Vec<Author>,
158}
159
160/// A Ghost membership tier (formerly "product").
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
162pub struct Tier {
163    /// Ghost object ID.
164    pub id: String,
165    /// Display name.
166    pub name: String,
167    /// URL slug.
168    pub slug: String,
169    /// Whether the tier is active.
170    #[serde(default)]
171    pub active: bool,
172    /// Tier type: `"free"` or `"paid"`.
173    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
174    pub tier_type: Option<String>,
175    /// Welcome page URL shown after subscription.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub welcome_page_url: Option<String>,
178    /// Short description of the tier.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub description: Option<String>,
181    /// List of benefit strings shown on the pricing page.
182    #[serde(default)]
183    pub benefits: Vec<String>,
184    /// Monthly price in the smallest currency unit (e.g. cents).
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub monthly_price: Option<u64>,
187    /// Yearly price in the smallest currency unit.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub yearly_price: Option<u64>,
190    /// ISO 4217 currency code, e.g. `"usd"`.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub currency: Option<String>,
193    /// ISO 8601 creation timestamp.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub created_at: Option<String>,
196    /// ISO 8601 last-updated timestamp.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub updated_at: Option<String>,
199}
200
201/// Response envelope for browse-tiers requests.
202#[derive(Debug, Deserialize)]
203pub struct TiersResponse {
204    /// Tiers returned by the API.
205    pub tiers: Vec<Tier>,
206    /// Pagination metadata.
207    pub meta: Meta,
208}
209
210/// Response envelope for the settings endpoint.
211#[derive(Debug, Deserialize)]
212pub struct SettingsResponse {
213    /// Site settings.
214    pub settings: Settings,
215}
216
217// ── Internal error shape ─────────────────────────────────────────────────────
218
219#[derive(Debug, Deserialize)]
220struct GhostApiErrors {
221    errors: Vec<GhostApiError>,
222}
223
224#[derive(Debug, Deserialize)]
225struct GhostApiError {
226    message: String,
227    #[serde(rename = "type")]
228    error_type: String,
229    context: Option<String>,
230}
231
232// ── Client ───────────────────────────────────────────────────────────────────
233
234/// Read-only client for the Ghost Content API.
235///
236/// Communicates with `/ghost/api/content/` using a [`ContentApiKey`] passed as
237/// a query parameter. All methods are `async` and require a Tokio runtime.
238///
239/// The client sets `Accept-Version: v5.0` on every request.
240///
241/// # Example
242///
243/// ```no_run
244/// use ghost_io_api::auth::content::ContentApiKey;
245/// use ghost_io_api::client::content::{GhostContentClient, BrowsePostsParams};
246///
247/// # async fn example() -> ghost_io_api::error::Result<()> {
248/// let key = ContentApiKey::new("22444f78447824223cefc48062")?;
249/// let client = GhostContentClient::new("https://demo.ghost.io", key)?;
250///
251/// // Browse posts
252/// let posts = client.browse_posts(BrowsePostsParams::default()).await?;
253///
254/// // Read by ID
255/// let post = client.read_post_by_id("5ddc9141c35e7700383b2937", None).await?;
256///
257/// // Read by slug
258/// let post = client.read_post_by_slug("welcome", None).await?;
259/// # Ok(())
260/// # }
261/// ```
262pub struct GhostContentClient {
263    base_url: String,
264    api_key: ContentApiKey,
265    http: Client,
266}
267
268impl GhostContentClient {
269    /// Creates a new `GhostContentClient`.
270    ///
271    /// Trailing slashes on `base_url` are stripped automatically.
272    ///
273    /// # Errors
274    ///
275    /// Returns `GhostError::Http` if the underlying `reqwest` client cannot be built.
276    pub fn new(base_url: impl Into<String>, api_key: ContentApiKey) -> Result<Self> {
277        let mut base_url = base_url.into();
278        while base_url.ends_with('/') {
279            base_url.pop();
280        }
281
282        let mut default_headers = header::HeaderMap::new();
283        default_headers.insert(
284            "Accept-Version",
285            header::HeaderValue::from_static(GHOST_API_VERSION),
286        );
287
288        let http = Client::builder()
289            .default_headers(default_headers)
290            .build()
291            .map_err(GhostError::Http)?;
292
293        Ok(Self {
294            base_url,
295            api_key,
296            http,
297        })
298    }
299
300    // ── Posts ────────────────────────────────────────────────────────────────
301
302    /// Browses published posts with optional filtering, sorting, and pagination.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
307    /// response, or a JSON decoding error.
308    pub async fn browse_posts(&self, params: BrowsePostsParams) -> Result<PostsResponse> {
309        let url = format!("{}{}/posts/", self.base_url, CONTENT_API_PATH);
310        let mut query = self.base_query();
311        query.extend(params.to_query_pairs());
312        let response = self.http.get(&url).query(&query).send().await?;
313        self.parse_response::<PostsResponse>(response).await
314    }
315
316    /// Reads a single post by its Ghost ID.
317    ///
318    /// `include` is an optional comma-separated list of relations to embed, e.g. `"authors,tags"`.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
323    /// post matches the ID, or a network / decoding error otherwise.
324    pub async fn read_post_by_id(&self, id: &str, include: Option<&str>) -> Result<Post> {
325        let url = format!("{}{}/posts/{}/", self.base_url, CONTENT_API_PATH, id);
326        self.read_single_item::<PostResponse, Post>(&url, include, |r| r.posts)
327            .await
328            .and_then(|opt| {
329                opt.ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
330            })
331    }
332
333    /// Reads a single post by its slug.
334    ///
335    /// `include` is an optional comma-separated list of relations to embed, e.g. `"authors,tags"`.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
340    /// post matches the slug, or a network / decoding error otherwise.
341    pub async fn read_post_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Post> {
342        let url = format!("{}{}/posts/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
343        self.read_single_item::<PostResponse, Post>(&url, include, |r| r.posts)
344            .await
345            .and_then(|opt| {
346                opt.ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
347            })
348    }
349
350    // ── Pages ────────────────────────────────────────────────────────────────
351
352    /// Browses published pages with optional filtering, sorting, and pagination.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
357    /// response, or a JSON decoding error.
358    pub async fn browse_pages(&self, params: BrowsePagesParams) -> Result<PagesResponse> {
359        let url = format!("{}{}/pages/", self.base_url, CONTENT_API_PATH);
360        let mut query = self.base_query();
361        query.extend(params.to_query_pairs());
362        let response = self.http.get(&url).query(&query).send().await?;
363        self.parse_response::<PagesResponse>(response).await
364    }
365
366    /// Reads a single page by its Ghost ID.
367    ///
368    /// `include` is an optional comma-separated list of relations to embed, e.g. `"authors"`.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
373    /// page matches the ID, or a network / decoding error otherwise.
374    pub async fn read_page_by_id(&self, id: &str, include: Option<&str>) -> Result<Page> {
375        let url = format!("{}{}/pages/{}/", self.base_url, CONTENT_API_PATH, id);
376        self.read_single_item::<PageResponse, Page>(&url, include, |r| r.pages)
377            .await
378            .and_then(|opt| {
379                opt.ok_or_else(|| GhostError::api("Page not found", "NotFoundError", None))
380            })
381    }
382
383    /// Reads a single page by its slug.
384    ///
385    /// `include` is an optional comma-separated list of relations to embed, e.g. `"authors"`.
386    ///
387    /// # Errors
388    ///
389    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
390    /// page matches the slug, or a network / decoding error otherwise.
391    pub async fn read_page_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Page> {
392        let url = format!("{}{}/pages/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
393        self.read_single_item::<PageResponse, Page>(&url, include, |r| r.pages)
394            .await
395            .and_then(|opt| {
396                opt.ok_or_else(|| GhostError::api("Page not found", "NotFoundError", None))
397            })
398    }
399
400    // ── Tags ─────────────────────────────────────────────────────────────────
401
402    /// Browses tags with optional filtering, sorting, and pagination.
403    ///
404    /// # Errors
405    ///
406    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
407    /// response, or a JSON decoding error.
408    pub async fn browse_tags(&self, params: BrowseTagsParams) -> Result<TagsResponse> {
409        let url = format!("{}{}/tags/", self.base_url, CONTENT_API_PATH);
410        let mut query = self.base_query();
411        query.extend(params.to_query_pairs());
412        let response = self.http.get(&url).query(&query).send().await?;
413        self.parse_response::<TagsResponse>(response).await
414    }
415
416    /// Reads a single tag by its Ghost ID.
417    ///
418    /// `include` accepts `"count.posts"` to embed the post count for this tag.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
423    /// tag matches the ID, or a network / decoding error otherwise.
424    pub async fn read_tag_by_id(&self, id: &str, include: Option<&str>) -> Result<Tag> {
425        let url = format!("{}{}/tags/{}/", self.base_url, CONTENT_API_PATH, id);
426        self.read_single_item::<TagResponse, Tag>(&url, include, |r| r.tags)
427            .await
428            .and_then(|opt| {
429                opt.ok_or_else(|| GhostError::api("Tag not found", "NotFoundError", None))
430            })
431    }
432
433    /// Reads a single tag by its slug.
434    ///
435    /// `include` accepts `"count.posts"` to embed the post count for this tag.
436    ///
437    /// # Errors
438    ///
439    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
440    /// tag matches the slug, or a network / decoding error otherwise.
441    pub async fn read_tag_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Tag> {
442        let url = format!("{}{}/tags/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
443        self.read_single_item::<TagResponse, Tag>(&url, include, |r| r.tags)
444            .await
445            .and_then(|opt| {
446                opt.ok_or_else(|| GhostError::api("Tag not found", "NotFoundError", None))
447            })
448    }
449
450    // ── Authors ──────────────────────────────────────────────────────────────
451
452    /// Browses authors with optional filtering, sorting, and pagination.
453    ///
454    /// # Errors
455    ///
456    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
457    /// response, or a JSON decoding error.
458    pub async fn browse_authors(&self, params: BrowseAuthorsParams) -> Result<AuthorsResponse> {
459        let url = format!("{}{}/authors/", self.base_url, CONTENT_API_PATH);
460        let mut query = self.base_query();
461        query.extend(params.to_query_pairs());
462        let response = self.http.get(&url).query(&query).send().await?;
463        self.parse_response::<AuthorsResponse>(response).await
464    }
465
466    /// Reads a single author by their Ghost ID.
467    ///
468    /// `include` accepts `"count.posts"` to embed the post count for this author.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
473    /// author matches the ID, or a network / decoding error otherwise.
474    pub async fn read_author_by_id(&self, id: &str, include: Option<&str>) -> Result<Author> {
475        let url = format!("{}{}/authors/{}/", self.base_url, CONTENT_API_PATH, id);
476        self.read_single_item::<AuthorResponse, Author>(&url, include, |r| r.authors)
477            .await
478            .and_then(|opt| {
479                opt.ok_or_else(|| GhostError::api("Author not found", "NotFoundError", None))
480            })
481    }
482
483    /// Reads a single author by their slug.
484    ///
485    /// `include` accepts `"count.posts"` to embed the post count for this author.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`GhostError::Api`] with `"NotFoundError"` if no
490    /// author matches the slug, or a network / decoding error otherwise.
491    pub async fn read_author_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Author> {
492        let url = format!(
493            "{}{}/authors/slug/{}/",
494            self.base_url, CONTENT_API_PATH, slug
495        );
496        self.read_single_item::<AuthorResponse, Author>(&url, include, |r| r.authors)
497            .await
498            .and_then(|opt| {
499                opt.ok_or_else(|| GhostError::api("Author not found", "NotFoundError", None))
500            })
501    }
502
503    // ── Tiers ────────────────────────────────────────────────────────────────
504
505    /// Browses membership tiers (free and paid).
506    ///
507    /// # Errors
508    ///
509    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
510    /// response, or a JSON decoding error.
511    pub async fn browse_tiers(&self, params: BrowseTiersParams) -> Result<TiersResponse> {
512        let url = format!("{}{}/tiers/", self.base_url, CONTENT_API_PATH);
513        let mut query = self.base_query();
514        query.extend(params.to_query_pairs());
515        let response = self.http.get(&url).query(&query).send().await?;
516        self.parse_response::<TiersResponse>(response).await
517    }
518
519    // ── Settings ─────────────────────────────────────────────────────────────
520
521    /// Fetches site-wide settings (title, logo, navigation, social links, etc.).
522    ///
523    /// # Errors
524    ///
525    /// Returns [`GhostError`] on network failure, a non-2xx HTTP
526    /// response, or a JSON decoding error.
527    pub async fn get_settings(&self) -> Result<Settings> {
528        let url = format!("{}{}/settings/", self.base_url, CONTENT_API_PATH);
529        let query = self.base_query();
530        let response = self.http.get(&url).query(&query).send().await?;
531        self.parse_response::<SettingsResponse>(response)
532            .await
533            .map(|r| r.settings)
534    }
535
536    // ── Private helpers ───────────────────────────────────────────────────────
537
538    fn base_query(&self) -> Vec<(&'static str, String)> {
539        vec![("key", self.api_key.as_str().to_string())]
540    }
541
542    async fn read_single_item<E, T>(
543        &self,
544        url: &str,
545        include: Option<&str>,
546        extract: impl FnOnce(E) -> Vec<T>,
547    ) -> Result<Option<T>>
548    where
549        E: for<'de> Deserialize<'de>,
550    {
551        let mut query = self.base_query();
552        if let Some(inc) = include {
553            query.push(("include", inc.to_string()));
554        }
555        let response = self.http.get(url).query(&query).send().await?;
556        let envelope = self.parse_response::<E>(response).await?;
557        Ok(extract(envelope).into_iter().next())
558    }
559
560    async fn parse_response<T: for<'de> Deserialize<'de>>(
561        &self,
562        response: reqwest::Response,
563    ) -> Result<T> {
564        let status = response.status();
565        if status.is_success() {
566            Ok(response.json::<T>().await?)
567        } else {
568            let api_errors: GhostApiErrors = response.json().await?;
569            let first = api_errors
570                .errors
571                .into_iter()
572                .next()
573                .unwrap_or(GhostApiError {
574                    message: "Unknown API error".to_string(),
575                    error_type: "UnknownError".to_string(),
576                    context: None,
577                });
578            Err(GhostError::api(
579                first.message,
580                first.error_type,
581                first.context,
582            ))
583        }
584    }
585}
586
587// ── Unit tests ────────────────────────────────────────────────────────────────
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    const VALID_KEY: &str = "22444f78447824223cefc48062";
594
595    fn make_key() -> ContentApiKey {
596        ContentApiKey::new(VALID_KEY).unwrap()
597    }
598
599    fn make_client() -> GhostContentClient {
600        GhostContentClient::new("https://demo.ghost.io", make_key()).unwrap()
601    }
602
603    // ── Construction ──────────────────────────────────────────────────────────
604
605    #[test]
606    fn test_client_creation() {
607        assert!(GhostContentClient::new("https://demo.ghost.io", make_key()).is_ok());
608    }
609
610    #[test]
611    fn test_client_strips_trailing_slash() {
612        let client = GhostContentClient::new("https://demo.ghost.io/", make_key()).unwrap();
613        assert_eq!(client.base_url, "https://demo.ghost.io");
614    }
615
616    #[test]
617    fn test_client_strips_multiple_trailing_slashes() {
618        let client = GhostContentClient::new("https://demo.ghost.io///", make_key()).unwrap();
619        assert_eq!(client.base_url, "https://demo.ghost.io");
620    }
621
622    // ── base_query ────────────────────────────────────────────────────────────
623
624    #[test]
625    fn test_base_query_contains_key() {
626        let client = make_client();
627        let query = client.base_query();
628        assert_eq!(query.len(), 1);
629        assert_eq!(query[0], ("key", VALID_KEY.to_string()));
630    }
631
632    // ── BrowsePostsParams ─────────────────────────────────────────────────────
633
634    #[test]
635    fn test_browse_params_default_empty() {
636        let params = BrowsePostsParams::default();
637        assert!(params.to_query_pairs().is_empty());
638    }
639
640    #[test]
641    fn test_browse_params_all_fields() {
642        let params = BrowsePostsParams {
643            page: Some(2),
644            limit: Some(10),
645            include: Some("authors,tags".to_string()),
646            fields: Some("id,title".to_string()),
647            filter: Some("featured:true".to_string()),
648            order: Some("published_at DESC".to_string()),
649        };
650        let pairs = params.to_query_pairs();
651        assert_eq!(pairs.len(), 6);
652
653        let map: std::collections::HashMap<_, _> = pairs.into_iter().collect();
654        assert_eq!(map["page"], "2");
655        assert_eq!(map["limit"], "10");
656        assert_eq!(map["include"], "authors,tags");
657        assert_eq!(map["fields"], "id,title");
658        assert_eq!(map["filter"], "featured:true");
659        assert_eq!(map["order"], "published_at DESC");
660    }
661
662    #[test]
663    fn test_browse_params_partial() {
664        let params = BrowsePostsParams {
665            page: Some(1),
666            include: Some("authors".to_string()),
667            ..Default::default()
668        };
669        assert_eq!(params.to_query_pairs().len(), 2);
670    }
671
672    // ── Tier model ────────────────────────────────────────────────────────────
673
674    #[test]
675    fn test_tier_deserialization() {
676        let json = serde_json::json!({
677            "id": "tier1",
678            "name": "Free",
679            "slug": "free",
680            "active": true,
681            "type": "free"
682        });
683        let tier: Tier = serde_json::from_value(json).unwrap();
684        assert_eq!(tier.id, "tier1");
685        assert!(tier.active);
686        assert_eq!(tier.tier_type.as_deref(), Some("free"));
687    }
688
689    #[test]
690    fn test_tier_default() {
691        let tier = Tier::default();
692        assert!(tier.id.is_empty());
693        assert!(!tier.active);
694        assert!(tier.benefits.is_empty());
695    }
696
697    #[test]
698    fn test_tier_with_pricing() {
699        let json = serde_json::json!({
700            "id": "tier2",
701            "name": "Supporter",
702            "slug": "supporter",
703            "active": true,
704            "type": "paid",
705            "monthly_price": 500,
706            "yearly_price": 5000,
707            "currency": "usd",
708            "benefits": ["No ads", "Newsletter"]
709        });
710        let tier: Tier = serde_json::from_value(json).unwrap();
711        assert_eq!(tier.monthly_price, Some(500));
712        assert_eq!(tier.yearly_price, Some(5000));
713        assert_eq!(tier.currency.as_deref(), Some("usd"));
714        assert_eq!(tier.benefits.len(), 2);
715    }
716
717    #[test]
718    fn test_tier_clone_eq() {
719        let tier = Tier {
720            id: "1".to_string(),
721            name: "Free".to_string(),
722            slug: "free".to_string(),
723            ..Default::default()
724        };
725        assert_eq!(tier, tier.clone());
726    }
727
728    // ── Response envelope deserialization ─────────────────────────────────────
729
730    #[test]
731    fn test_settings_response_deserialization() {
732        let json = serde_json::json!({
733            "settings": {
734                "title": "Test Blog",
735                "lang": "en"
736            }
737        });
738        let resp: SettingsResponse = serde_json::from_value(json).unwrap();
739        assert_eq!(resp.settings.title.as_deref(), Some("Test Blog"));
740    }
741
742    #[test]
743    fn test_pages_response_deserialization() {
744        let json = serde_json::json!({
745            "pages": [
746                { "id": "1", "title": "About", "slug": "about" }
747            ],
748            "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
749        });
750        let resp: PagesResponse = serde_json::from_value(json).unwrap();
751        assert_eq!(resp.pages.len(), 1);
752        assert_eq!(resp.pages[0].slug, "about");
753    }
754
755    #[test]
756    fn test_tags_response_deserialization() {
757        let json = serde_json::json!({
758            "tags": [
759                { "id": "t1", "name": "Rust", "slug": "rust" }
760            ],
761            "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
762        });
763        let resp: TagsResponse = serde_json::from_value(json).unwrap();
764        assert_eq!(resp.tags.len(), 1);
765        assert_eq!(resp.tags[0].name, "Rust");
766    }
767
768    #[test]
769    fn test_authors_response_deserialization() {
770        let json = serde_json::json!({
771            "authors": [
772                { "id": "a1", "name": "Jane", "slug": "jane" }
773            ],
774            "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
775        });
776        let resp: AuthorsResponse = serde_json::from_value(json).unwrap();
777        assert_eq!(resp.authors.len(), 1);
778        assert_eq!(resp.authors[0].name, "Jane");
779    }
780
781    #[test]
782    fn test_tiers_response_deserialization() {
783        let json = serde_json::json!({
784            "tiers": [
785                { "id": "t1", "name": "Free", "slug": "free", "active": true }
786            ],
787            "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
788        });
789        let resp: TiersResponse = serde_json::from_value(json).unwrap();
790        assert_eq!(resp.tiers.len(), 1);
791        assert!(resp.tiers[0].active);
792    }
793}
794
795// ── Integration tests (requires --features integration-tests) ─────────────────
796
797#[cfg(test)]
798#[cfg(feature = "integration-tests")]
799mod integration_tests {
800    use super::*;
801
802    const DEMO_URL: &str = "https://demo.ghost.io";
803    const DEMO_KEY: &str = "22444f78447824223cefc48062";
804
805    fn make_client() -> GhostContentClient {
806        let key = ContentApiKey::new(DEMO_KEY).unwrap();
807        GhostContentClient::new(DEMO_URL, key).unwrap()
808    }
809
810    #[tokio::test]
811    async fn test_browse_posts_integration() {
812        let client = make_client();
813        let result = client.browse_posts(BrowsePostsParams::default()).await;
814        assert!(result.is_ok(), "browse_posts failed: {:?}", result);
815        let response = result.unwrap();
816        assert!(!response.posts.is_empty());
817        assert!(response.meta.pagination.page >= 1);
818    }
819
820    #[tokio::test]
821    async fn test_browse_posts_with_limit() {
822        let client = make_client();
823        let params = BrowsePostsParams {
824            limit: Some(2),
825            ..Default::default()
826        };
827        let result = client.browse_posts(params).await.unwrap();
828        assert!(result.posts.len() <= 2);
829    }
830
831    #[tokio::test]
832    async fn test_read_post_by_slug_integration() {
833        let client = make_client();
834        let browse = client
835            .browse_posts(BrowsePostsParams {
836                limit: Some(1),
837                ..Default::default()
838            })
839            .await
840            .unwrap();
841        let slug = &browse.posts[0].slug;
842        let post = client.read_post_by_slug(slug, None).await.unwrap();
843        assert_eq!(&post.slug, slug);
844    }
845
846    #[tokio::test]
847    async fn test_read_post_by_id_integration() {
848        let client = make_client();
849        let browse = client
850            .browse_posts(BrowsePostsParams {
851                limit: Some(1),
852                ..Default::default()
853            })
854            .await
855            .unwrap();
856        let id = &browse.posts[0].id;
857        let post = client.read_post_by_id(id, None).await.unwrap();
858        assert_eq!(&post.id, id);
859    }
860
861    #[tokio::test]
862    async fn test_read_post_not_found() {
863        let client = make_client();
864        let err = client
865            .read_post_by_id("000000000000000000000000", None)
866            .await
867            .unwrap_err();
868        assert!(err.is_api_error());
869    }
870
871    #[tokio::test]
872    async fn test_browse_pages_integration() {
873        let client = make_client();
874        let result = client.browse_pages(BrowsePagesParams::default()).await;
875        assert!(result.is_ok(), "browse_pages failed: {:?}", result);
876    }
877
878    #[tokio::test]
879    async fn test_browse_tags_integration() {
880        let client = make_client();
881        let result = client.browse_tags(BrowseTagsParams::default()).await;
882        assert!(result.is_ok(), "browse_tags failed: {:?}", result);
883        assert!(!result.unwrap().tags.is_empty());
884    }
885
886    #[tokio::test]
887    async fn test_read_tag_by_slug_integration() {
888        let client = make_client();
889        let tags = client
890            .browse_tags(BrowseTagsParams {
891                limit: Some(1),
892                ..Default::default()
893            })
894            .await
895            .unwrap();
896        let slug = &tags.tags[0].slug;
897        let tag = client.read_tag_by_slug(slug, None).await.unwrap();
898        assert_eq!(&tag.slug, slug);
899    }
900
901    #[tokio::test]
902    async fn test_browse_authors_integration() {
903        let client = make_client();
904        let result = client.browse_authors(BrowseAuthorsParams::default()).await;
905        assert!(result.is_ok(), "browse_authors failed: {:?}", result);
906        assert!(!result.unwrap().authors.is_empty());
907    }
908
909    #[tokio::test]
910    async fn test_get_settings_integration() {
911        let client = make_client();
912        let result = client.get_settings().await;
913        assert!(result.is_ok(), "get_settings failed: {:?}", result);
914        let settings = result.unwrap();
915        assert!(settings.title.is_some());
916    }
917}