Skip to main content

ghost_io_api/client/
admin.rs

1//! Ghost Admin API client.
2//!
3//! Provides write-capable access to Ghost via the Admin API. Every request
4//! carries a freshly-signed HS256 JWT in the `Authorization: Ghost <token>`
5//! header so that tokens never expire mid-flight.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use ghost_io_api::auth::admin::AdminApiKey;
11//! use ghost_io_api::client::admin::{GhostAdminClient, AdminBrowsePostsParams};
12//! use ghost_io_api::models::post::PostCreate;
13//!
14//! # async fn example() -> ghost_io_api::error::Result<()> {
15//! let key = AdminApiKey::new(
16//!     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
17//! )?;
18//! let client = GhostAdminClient::new("https://example.ghost.io", key)?;
19//!
20//! // Browse posts
21//! let response = client.browse_posts(AdminBrowsePostsParams::default()).await?;
22//! println!("Found {} posts", response.posts.len());
23//!
24//! // Create a post
25//! let post = client.create_post(PostCreate::new("Hello, Admin!")).await?;
26//! println!("Created: {}", post.id);
27//! # Ok(())
28//! # }
29//! ```
30
31use crate::auth::admin::AdminApiKey;
32use crate::error::{GhostError, Result};
33use crate::models::pagination::Meta;
34use crate::models::post::{Post, PostCreate, PostCreateEnvelope, PostUpdate, PostUpdateEnvelope};
35use reqwest::{header, Client};
36use serde::{Deserialize, Serialize};
37
38const GHOST_API_VERSION: &str = "v5.0";
39const ADMIN_API_PATH: &str = "/ghost/api/admin";
40
41// ── Internal error shape ─────────────────────────────────────────────────────
42
43#[derive(Debug, Deserialize)]
44struct GhostApiErrors {
45    errors: Vec<GhostApiError>,
46}
47
48#[derive(Debug, Deserialize)]
49struct GhostApiError {
50    message: String,
51    #[serde(rename = "type")]
52    error_type: String,
53    context: Option<String>,
54}
55
56// ── Internal response envelopes ───────────────────────────────────────────────
57
58#[derive(Debug, Deserialize)]
59struct AdminPostEnvelope {
60    posts: Vec<Post>,
61}
62
63// ── Public types ──────────────────────────────────────────────────────────────
64
65/// Query parameters for browsing admin posts.
66///
67/// All fields are optional; omitted fields cause Ghost to apply its defaults
68/// (page 1, limit 15, most-recently-updated order).
69///
70/// # Example
71///
72/// ```
73/// use ghost_io_api::client::admin::AdminBrowsePostsParams;
74///
75/// let params = AdminBrowsePostsParams {
76///     limit: Some(5),
77///     filter: Some("status:draft".to_string()),
78///     ..Default::default()
79/// };
80/// ```
81#[derive(Debug, Clone, Default)]
82pub struct AdminBrowsePostsParams {
83    /// Page number (1-indexed).
84    pub page: Option<u32>,
85    /// Number of posts per page.
86    pub limit: Option<u32>,
87    /// NQL filter expression, e.g. `"status:draft"` or `"featured:true"`.
88    pub filter: Option<String>,
89    /// Sort order, e.g. `"published_at DESC"`.
90    pub order: Option<String>,
91    /// Comma-separated relations to embed, e.g. `"authors,tags"`.
92    pub include: Option<String>,
93    /// Comma-separated field names to return.
94    pub fields: Option<String>,
95}
96
97impl AdminBrowsePostsParams {
98    fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
99        let mut pairs: Vec<(&'static str, String)> = Vec::new();
100        if let Some(v) = self.page {
101            pairs.push(("page", v.to_string()));
102        }
103        if let Some(v) = self.limit {
104            pairs.push(("limit", v.to_string()));
105        }
106        if let Some(ref v) = self.filter {
107            pairs.push(("filter", v.clone()));
108        }
109        if let Some(ref v) = self.order {
110            pairs.push(("order", v.clone()));
111        }
112        if let Some(ref v) = self.include {
113            pairs.push(("include", v.clone()));
114        }
115        if let Some(ref v) = self.fields {
116            pairs.push(("fields", v.clone()));
117        }
118        pairs
119    }
120}
121
122/// Response from [`GhostAdminClient::browse_posts`].
123#[derive(Debug, Deserialize)]
124pub struct AdminPostsResponse {
125    /// Posts returned by this page.
126    pub posts: Vec<Post>,
127    /// Pagination metadata.
128    pub meta: Meta,
129}
130
131// ── Client ───────────────────────────────────────────────────────────────────
132
133/// Write-capable client for the Ghost Admin API.
134///
135/// Communicates with `/ghost/api/admin/` using a [`AdminApiKey`] that is
136/// converted to a short-lived HS256 JWT on **every request** so tokens never
137/// sit long enough to expire. The `Accept-Version: v5.0` header is sent
138/// automatically.
139///
140/// # Security
141///
142/// Keep the [`AdminApiKey`] secret — it grants write access to your Ghost
143/// installation.
144///
145/// # Example
146///
147/// ```no_run
148/// use ghost_io_api::auth::admin::AdminApiKey;
149/// use ghost_io_api::client::admin::GhostAdminClient;
150///
151/// # async fn example() -> ghost_io_api::error::Result<()> {
152/// let key = AdminApiKey::new(
153///     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
154/// )?;
155/// let client = GhostAdminClient::new("https://example.ghost.io", key)?;
156/// # Ok(())
157/// # }
158/// ```
159pub struct GhostAdminClient {
160    base_url: String,
161    api_key: AdminApiKey,
162    http: Client,
163}
164
165impl GhostAdminClient {
166    /// Creates a new `GhostAdminClient`.
167    ///
168    /// Trailing slashes on `base_url` are stripped automatically.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`GhostError::Http`] if the underlying `reqwest` client cannot
173    /// be built.
174    ///
175    /// # Example
176    ///
177    /// ```no_run
178    /// use ghost_io_api::auth::admin::AdminApiKey;
179    /// use ghost_io_api::client::admin::GhostAdminClient;
180    ///
181    /// let key = AdminApiKey::new(
182    ///     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
183    /// ).unwrap();
184    /// let client = GhostAdminClient::new("https://example.ghost.io", key).unwrap();
185    /// ```
186    pub fn new(base_url: impl Into<String>, api_key: AdminApiKey) -> Result<Self> {
187        let mut base_url = base_url.into();
188        while base_url.ends_with('/') {
189            base_url.pop();
190        }
191
192        let mut default_headers = header::HeaderMap::new();
193        default_headers.insert(
194            "Accept-Version",
195            header::HeaderValue::from_static(GHOST_API_VERSION),
196        );
197
198        let http = Client::builder()
199            .default_headers(default_headers)
200            .build()
201            .map_err(GhostError::Http)?;
202
203        Ok(Self {
204            base_url,
205            api_key,
206            http,
207        })
208    }
209
210    /// Returns the normalised base URL (no trailing slash).
211    pub fn base_url(&self) -> &str {
212        &self.base_url
213    }
214
215    /// Returns a reference to the stored [`AdminApiKey`].
216    pub fn api_key(&self) -> &AdminApiKey {
217        &self.api_key
218    }
219
220    // ── Posts CRUD ────────────────────────────────────────────────────────────
221
222    /// Browses posts with optional filtering, sorting, and pagination.
223    ///
224    /// Unlike the Content API, the Admin API returns all posts regardless of
225    /// status (drafts, scheduled, published).
226    ///
227    /// # Errors
228    ///
229    /// Returns [`GhostError`] on network failure, a non-2xx HTTP response, or
230    /// a JSON decoding error.
231    ///
232    /// # Example
233    ///
234    /// ```no_run
235    /// use ghost_io_api::client::admin::{GhostAdminClient, AdminBrowsePostsParams};
236    /// # use ghost_io_api::auth::admin::AdminApiKey;
237    /// # async fn example() -> ghost_io_api::error::Result<()> {
238    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
239    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
240    /// let response = client.browse_posts(AdminBrowsePostsParams {
241    ///     limit: Some(10),
242    ///     filter: Some("status:draft".to_string()),
243    ///     ..Default::default()
244    /// }).await?;
245    /// println!("{} drafts found", response.posts.len());
246    /// # Ok(()) }
247    /// ```
248    pub async fn browse_posts(&self, params: AdminBrowsePostsParams) -> Result<AdminPostsResponse> {
249        let query = params.to_query_pairs();
250        self.get("/posts/", &query).await
251    }
252
253    /// Reads a single post by its Ghost ID.
254    ///
255    /// `include` is an optional comma-separated list of relations to embed,
256    /// e.g. `"authors,tags"`.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`GhostError::Api`] with type `"NotFoundError"` when no post
261    /// matches `id`.
262    ///
263    /// # Example
264    ///
265    /// ```no_run
266    /// # use ghost_io_api::auth::admin::AdminApiKey;
267    /// # use ghost_io_api::client::admin::GhostAdminClient;
268    /// # async fn example() -> ghost_io_api::error::Result<()> {
269    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
270    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
271    /// let post = client.read_post_by_id("5ddc9141c35e7700383b2937", None).await?;
272    /// println!("{}", post.title);
273    /// # Ok(()) }
274    /// ```
275    pub async fn read_post_by_id(&self, id: &str, include: Option<&str>) -> Result<Post> {
276        let path = format!("/posts/{id}/");
277        let query = include
278            .map(|i| vec![("include", i.to_string())])
279            .unwrap_or_default();
280        let envelope: AdminPostEnvelope = self.get(&path, &query).await?;
281        envelope
282            .posts
283            .into_iter()
284            .next()
285            .ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
286    }
287
288    /// Reads a single post by its slug.
289    ///
290    /// `include` is an optional comma-separated list of relations to embed,
291    /// e.g. `"authors,tags"`.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`GhostError::Api`] with type `"NotFoundError"` when no post
296    /// matches `slug`.
297    ///
298    /// # Example
299    ///
300    /// ```no_run
301    /// # use ghost_io_api::auth::admin::AdminApiKey;
302    /// # use ghost_io_api::client::admin::GhostAdminClient;
303    /// # async fn example() -> ghost_io_api::error::Result<()> {
304    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
305    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
306    /// let post = client.read_post_by_slug("welcome", Some("authors")).await?;
307    /// println!("{}", post.title);
308    /// # Ok(()) }
309    /// ```
310    pub async fn read_post_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Post> {
311        let path = format!("/posts/slug/{slug}/");
312        let query = include
313            .map(|i| vec![("include", i.to_string())])
314            .unwrap_or_default();
315        let envelope: AdminPostEnvelope = self.get(&path, &query).await?;
316        envelope
317            .posts
318            .into_iter()
319            .next()
320            .ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
321    }
322
323    /// Creates a new post.
324    ///
325    /// Returns the created post as stored by Ghost (with server-assigned `id`,
326    /// `slug`, `created_at`, `updated_at`, etc.).
327    ///
328    /// # Errors
329    ///
330    /// Returns [`GhostError::Api`] on validation failures (e.g. empty title).
331    ///
332    /// # Example
333    ///
334    /// ```no_run
335    /// # use ghost_io_api::auth::admin::AdminApiKey;
336    /// # use ghost_io_api::client::admin::GhostAdminClient;
337    /// use ghost_io_api::models::post::{PostCreate, PostStatus};
338    /// # async fn example() -> ghost_io_api::error::Result<()> {
339    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
340    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
341    /// let post = client.create_post(PostCreate {
342    ///     title: "My New Post".to_string(),
343    ///     status: Some(PostStatus::Draft),
344    ///     ..Default::default()
345    /// }).await?;
346    /// println!("Created post: {}", post.id);
347    /// # Ok(()) }
348    /// ```
349    pub async fn create_post(&self, post: PostCreate) -> Result<Post> {
350        let envelope = PostCreateEnvelope::new(post);
351        let response: AdminPostEnvelope = self.post("/posts/", &envelope).await?;
352        response
353            .posts
354            .into_iter()
355            .next()
356            .ok_or_else(|| GhostError::api("No post in create response", "ServerError", None))
357    }
358
359    /// Updates an existing post.
360    ///
361    /// The [`PostUpdate`] must carry the post's current `updated_at` timestamp
362    /// (obtained from a prior read). Ghost rejects the request if the value
363    /// does not match, protecting against concurrent-edit overwrites.
364    ///
365    /// Returns the updated post as stored by Ghost.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`GhostError::Api`] with type `"NotFoundError"` if `id` is
370    /// unknown, or `"ConflictError"` if `updated_at` is stale.
371    ///
372    /// # Example
373    ///
374    /// ```no_run
375    /// # use ghost_io_api::auth::admin::AdminApiKey;
376    /// # use ghost_io_api::client::admin::GhostAdminClient;
377    /// use ghost_io_api::models::post::{PostUpdate, PostStatus};
378    /// # async fn example() -> ghost_io_api::error::Result<()> {
379    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
380    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
381    /// let post = client.read_post_by_id("abc123", None).await?;
382    /// let updated = client.update_post("abc123", PostUpdate {
383    ///     updated_at: post.updated_at.unwrap(),
384    ///     status: Some(PostStatus::Published),
385    ///     ..Default::default()
386    /// }).await?;
387    /// println!("Now published: {}", updated.id);
388    /// # Ok(()) }
389    /// ```
390    pub async fn update_post(&self, id: &str, post: PostUpdate) -> Result<Post> {
391        let path = format!("/posts/{id}/");
392        let envelope = PostUpdateEnvelope::new(post);
393        let response: AdminPostEnvelope = self.put(&path, &envelope).await?;
394        response
395            .posts
396            .into_iter()
397            .next()
398            .ok_or_else(|| GhostError::api("No post in update response", "ServerError", None))
399    }
400
401    /// Deletes a post by its Ghost ID.
402    ///
403    /// Ghost returns `204 No Content` on success. The post is permanently
404    /// removed and cannot be recovered.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`GhostError::Api`] with type `"NotFoundError"` if `id` is
409    /// unknown.
410    ///
411    /// # Example
412    ///
413    /// ```no_run
414    /// # use ghost_io_api::auth::admin::AdminApiKey;
415    /// # use ghost_io_api::client::admin::GhostAdminClient;
416    /// # async fn example() -> ghost_io_api::error::Result<()> {
417    /// # let key = AdminApiKey::new("6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1")?;
418    /// # let client = GhostAdminClient::new("https://example.ghost.io", key)?;
419    /// client.delete_post("abc123").await?;
420    /// # Ok(()) }
421    /// ```
422    pub async fn delete_post(&self, id: &str) -> Result<()> {
423        let path = format!("/posts/{id}/");
424        self.delete(&path).await
425    }
426
427    // ── Internal plumbing ─────────────────────────────────────────────────────
428
429    pub(crate) fn admin_url(&self, path: &str) -> String {
430        format!("{}{}{}", self.base_url, ADMIN_API_PATH, path)
431    }
432
433    pub(crate) fn auth_header_value(&self) -> Result<header::HeaderValue> {
434        let token = self.api_key.generate_jwt()?;
435        let value = format!("Ghost {token}");
436        header::HeaderValue::from_str(&value)
437            .map_err(|e| GhostError::auth(format!("Invalid Authorization header value: {e}")))
438    }
439
440    pub(crate) async fn get<T>(&self, path: &str, query: &[(&str, String)]) -> Result<T>
441    where
442        T: for<'de> Deserialize<'de>,
443    {
444        let url = self.admin_url(path);
445        let auth = self.auth_header_value()?;
446        let response = self
447            .http
448            .get(&url)
449            .header(header::AUTHORIZATION, auth)
450            .query(query)
451            .send()
452            .await?;
453        self.parse_response(response).await
454    }
455
456    pub(crate) async fn post<T, B>(&self, path: &str, body: &B) -> Result<T>
457    where
458        T: for<'de> Deserialize<'de>,
459        B: Serialize,
460    {
461        let url = self.admin_url(path);
462        let auth = self.auth_header_value()?;
463        let response = self
464            .http
465            .post(&url)
466            .header(header::AUTHORIZATION, auth)
467            .json(body)
468            .send()
469            .await?;
470        self.parse_response(response).await
471    }
472
473    pub(crate) async fn put<T, B>(&self, path: &str, body: &B) -> Result<T>
474    where
475        T: for<'de> Deserialize<'de>,
476        B: Serialize,
477    {
478        let url = self.admin_url(path);
479        let auth = self.auth_header_value()?;
480        let response = self
481            .http
482            .put(&url)
483            .header(header::AUTHORIZATION, auth)
484            .json(body)
485            .send()
486            .await?;
487        self.parse_response(response).await
488    }
489
490    pub(crate) async fn delete(&self, path: &str) -> Result<()> {
491        let url = self.admin_url(path);
492        let auth = self.auth_header_value()?;
493        let response = self
494            .http
495            .delete(&url)
496            .header(header::AUTHORIZATION, auth)
497            .send()
498            .await?;
499        if response.status().is_success() {
500            Ok(())
501        } else {
502            let api_errors: GhostApiErrors = response.json().await?;
503            let first = api_errors
504                .errors
505                .into_iter()
506                .next()
507                .unwrap_or(GhostApiError {
508                    message: "Unknown API error".to_string(),
509                    error_type: "UnknownError".to_string(),
510                    context: None,
511                });
512            Err(GhostError::api(
513                first.message,
514                first.error_type,
515                first.context,
516            ))
517        }
518    }
519
520    async fn parse_response<T>(&self, response: reqwest::Response) -> Result<T>
521    where
522        T: for<'de> Deserialize<'de>,
523    {
524        let status = response.status();
525        if status.is_success() {
526            Ok(response.json::<T>().await?)
527        } else {
528            let api_errors: GhostApiErrors = response.json().await?;
529            let first = api_errors
530                .errors
531                .into_iter()
532                .next()
533                .unwrap_or(GhostApiError {
534                    message: "Unknown API error".to_string(),
535                    error_type: "UnknownError".to_string(),
536                    context: None,
537                });
538            Err(GhostError::api(
539                first.message,
540                first.error_type,
541                first.context,
542            ))
543        }
544    }
545}
546
547// ── Unit tests ────────────────────────────────────────────────────────────────
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use wiremock::matchers::{body_json, header_exists, method, path, query_param};
553    use wiremock::{Mock, MockServer, ResponseTemplate};
554
555    const VALID_KEY: &str =
556        "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1";
557
558    fn make_key() -> AdminApiKey {
559        AdminApiKey::new(VALID_KEY).unwrap()
560    }
561
562    fn make_client(uri: &str) -> GhostAdminClient {
563        GhostAdminClient::new(uri, make_key()).unwrap()
564    }
565
566    fn post_json(id: &str, title: &str, status: &str) -> serde_json::Value {
567        serde_json::json!({
568            "id": id,
569            "title": title,
570            "slug": title.to_lowercase().replace(' ', "-"),
571            "status": status,
572            "updated_at": "2026-01-01T00:00:00.000Z"
573        })
574    }
575
576    fn pagination_meta(page: u32, total: u32) -> serde_json::Value {
577        serde_json::json!({
578            "pagination": {
579                "page": page, "limit": 15,
580                "pages": 1, "total": total,
581                "next": null, "prev": null
582            }
583        })
584    }
585
586    // ── Construction (carried over) ───────────────────────────────────────────
587
588    #[test]
589    fn test_client_creation() {
590        assert!(GhostAdminClient::new("https://example.ghost.io", make_key()).is_ok());
591    }
592
593    #[test]
594    fn test_client_strips_trailing_slash() {
595        let c = GhostAdminClient::new("https://example.ghost.io/", make_key()).unwrap();
596        assert_eq!(c.base_url(), "https://example.ghost.io");
597    }
598
599    // ── AdminBrowsePostsParams ────────────────────────────────────────────────
600
601    #[test]
602    fn test_browse_params_default_empty() {
603        assert!(AdminBrowsePostsParams::default()
604            .to_query_pairs()
605            .is_empty());
606    }
607
608    #[test]
609    fn test_browse_params_all_fields() {
610        let p = AdminBrowsePostsParams {
611            page: Some(2),
612            limit: Some(5),
613            filter: Some("status:draft".to_string()),
614            order: Some("published_at DESC".to_string()),
615            include: Some("authors,tags".to_string()),
616            fields: Some("id,title".to_string()),
617        };
618        let pairs = p.to_query_pairs();
619        let map: std::collections::HashMap<_, _> = pairs.into_iter().collect();
620        assert_eq!(map["page"], "2");
621        assert_eq!(map["limit"], "5");
622        assert_eq!(map["filter"], "status:draft");
623        assert_eq!(map["order"], "published_at DESC");
624        assert_eq!(map["include"], "authors,tags");
625        assert_eq!(map["fields"], "id,title");
626    }
627
628    #[test]
629    fn test_browse_params_partial() {
630        let p = AdminBrowsePostsParams {
631            limit: Some(10),
632            ..Default::default()
633        };
634        let pairs = p.to_query_pairs();
635        assert_eq!(pairs.len(), 1);
636        assert_eq!(pairs[0], ("limit", "10".to_string()));
637    }
638
639    // ── browse_posts ──────────────────────────────────────────────────────────
640
641    #[tokio::test]
642    async fn test_browse_posts_returns_posts_and_meta() {
643        let server = MockServer::start().await;
644        Mock::given(method("GET"))
645            .and(path("/ghost/api/admin/posts/"))
646            .and(header_exists("Authorization"))
647            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
648                "posts": [post_json("id1", "First Post", "draft")],
649                "meta": pagination_meta(1, 1)
650            })))
651            .mount(&server)
652            .await;
653
654        let response = make_client(&server.uri())
655            .browse_posts(AdminBrowsePostsParams::default())
656            .await
657            .unwrap();
658
659        assert_eq!(response.posts.len(), 1);
660        assert_eq!(response.posts[0].id, "id1");
661        assert_eq!(response.posts[0].title, "First Post");
662        assert_eq!(response.meta.pagination.total, 1);
663    }
664
665    #[tokio::test]
666    async fn test_browse_posts_forwards_query_params() {
667        let server = MockServer::start().await;
668        Mock::given(method("GET"))
669            .and(path("/ghost/api/admin/posts/"))
670            .and(query_param("limit", "5"))
671            .and(query_param("filter", "status:draft"))
672            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
673                "posts": [],
674                "meta": pagination_meta(1, 0)
675            })))
676            .expect(1)
677            .mount(&server)
678            .await;
679
680        make_client(&server.uri())
681            .browse_posts(AdminBrowsePostsParams {
682                limit: Some(5),
683                filter: Some("status:draft".to_string()),
684                ..Default::default()
685            })
686            .await
687            .unwrap();
688
689        server.verify().await;
690    }
691
692    #[tokio::test]
693    async fn test_browse_posts_empty_result() {
694        let server = MockServer::start().await;
695        Mock::given(method("GET"))
696            .and(path("/ghost/api/admin/posts/"))
697            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
698                "posts": [],
699                "meta": pagination_meta(1, 0)
700            })))
701            .mount(&server)
702            .await;
703
704        let response = make_client(&server.uri())
705            .browse_posts(AdminBrowsePostsParams::default())
706            .await
707            .unwrap();
708
709        assert!(response.posts.is_empty());
710        assert_eq!(response.meta.pagination.total, 0);
711    }
712
713    // ── read_post_by_id ───────────────────────────────────────────────────────
714
715    #[tokio::test]
716    async fn test_read_post_by_id_success() {
717        let server = MockServer::start().await;
718        Mock::given(method("GET"))
719            .and(path("/ghost/api/admin/posts/abc123/"))
720            .and(header_exists("Authorization"))
721            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
722                "posts": [post_json("abc123", "Test Post", "published")]
723            })))
724            .mount(&server)
725            .await;
726
727        let post = make_client(&server.uri())
728            .read_post_by_id("abc123", None)
729            .await
730            .unwrap();
731
732        assert_eq!(post.id, "abc123");
733        assert_eq!(post.title, "Test Post");
734    }
735
736    #[tokio::test]
737    async fn test_read_post_by_id_with_include() {
738        let server = MockServer::start().await;
739        Mock::given(method("GET"))
740            .and(path("/ghost/api/admin/posts/abc123/"))
741            .and(query_param("include", "authors,tags"))
742            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
743                "posts": [post_json("abc123", "Test Post", "draft")]
744            })))
745            .expect(1)
746            .mount(&server)
747            .await;
748
749        make_client(&server.uri())
750            .read_post_by_id("abc123", Some("authors,tags"))
751            .await
752            .unwrap();
753
754        server.verify().await;
755    }
756
757    #[tokio::test]
758    async fn test_read_post_by_id_not_found() {
759        let server = MockServer::start().await;
760        Mock::given(method("GET"))
761            .and(path("/ghost/api/admin/posts/nope/"))
762            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
763                "errors": [{"message": "Post not found", "type": "NotFoundError", "context": null}]
764            })))
765            .mount(&server)
766            .await;
767
768        let err = make_client(&server.uri())
769            .read_post_by_id("nope", None)
770            .await
771            .unwrap_err();
772
773        assert!(err.is_api_error());
774        assert_eq!(err.api_error_type(), Some("NotFoundError"));
775    }
776
777    // ── read_post_by_slug ─────────────────────────────────────────────────────
778
779    #[tokio::test]
780    async fn test_read_post_by_slug_success() {
781        let server = MockServer::start().await;
782        Mock::given(method("GET"))
783            .and(path("/ghost/api/admin/posts/slug/my-post/"))
784            .and(header_exists("Authorization"))
785            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
786                "posts": [post_json("abc123", "My Post", "published")]
787            })))
788            .mount(&server)
789            .await;
790
791        let post = make_client(&server.uri())
792            .read_post_by_slug("my-post", None)
793            .await
794            .unwrap();
795
796        assert_eq!(post.id, "abc123");
797        assert_eq!(post.slug, "my-post");
798    }
799
800    #[tokio::test]
801    async fn test_read_post_by_slug_with_include() {
802        let server = MockServer::start().await;
803        Mock::given(method("GET"))
804            .and(path("/ghost/api/admin/posts/slug/hello/"))
805            .and(query_param("include", "authors"))
806            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
807                "posts": [post_json("id1", "Hello", "draft")]
808            })))
809            .expect(1)
810            .mount(&server)
811            .await;
812
813        make_client(&server.uri())
814            .read_post_by_slug("hello", Some("authors"))
815            .await
816            .unwrap();
817
818        server.verify().await;
819    }
820
821    #[tokio::test]
822    async fn test_read_post_by_slug_not_found() {
823        let server = MockServer::start().await;
824        Mock::given(method("GET"))
825            .and(path("/ghost/api/admin/posts/slug/nope/"))
826            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
827                "errors": [{"message": "Post not found", "type": "NotFoundError", "context": null}]
828            })))
829            .mount(&server)
830            .await;
831
832        let err = make_client(&server.uri())
833            .read_post_by_slug("nope", None)
834            .await
835            .unwrap_err();
836
837        assert_eq!(err.api_error_type(), Some("NotFoundError"));
838    }
839
840    // ── create_post ───────────────────────────────────────────────────────────
841
842    #[tokio::test]
843    async fn test_create_post_sends_envelope() {
844        let server = MockServer::start().await;
845
846        let expected_body = serde_json::json!({
847            "posts": [{"title": "New Post"}]
848        });
849
850        Mock::given(method("POST"))
851            .and(path("/ghost/api/admin/posts/"))
852            .and(header_exists("Authorization"))
853            .and(body_json(&expected_body))
854            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
855                "posts": [post_json("new-id", "New Post", "draft")]
856            })))
857            .expect(1)
858            .mount(&server)
859            .await;
860
861        let post = make_client(&server.uri())
862            .create_post(PostCreate::new("New Post"))
863            .await
864            .unwrap();
865
866        assert_eq!(post.id, "new-id");
867        assert_eq!(post.title, "New Post");
868        server.verify().await;
869    }
870
871    #[tokio::test]
872    async fn test_create_post_with_status() {
873        let server = MockServer::start().await;
874
875        let expected_body = serde_json::json!({
876            "posts": [{"title": "Published", "status": "published"}]
877        });
878
879        Mock::given(method("POST"))
880            .and(path("/ghost/api/admin/posts/"))
881            .and(body_json(&expected_body))
882            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
883                "posts": [post_json("pub-id", "Published", "published")]
884            })))
885            .mount(&server)
886            .await;
887
888        use crate::models::post::PostStatus;
889        let post = make_client(&server.uri())
890            .create_post(PostCreate {
891                title: "Published".to_string(),
892                status: Some(PostStatus::Published),
893                ..Default::default()
894            })
895            .await
896            .unwrap();
897
898        assert_eq!(post.id, "pub-id");
899    }
900
901    #[tokio::test]
902    async fn test_create_post_api_error() {
903        let server = MockServer::start().await;
904        Mock::given(method("POST"))
905            .and(path("/ghost/api/admin/posts/"))
906            .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
907                "errors": [{"message": "Validation failed", "type": "ValidationError", "context": "Title is required"}]
908            })))
909            .mount(&server)
910            .await;
911
912        let err = make_client(&server.uri())
913            .create_post(PostCreate::new(""))
914            .await
915            .unwrap_err();
916
917        assert!(err.is_api_error());
918        assert_eq!(err.api_error_type(), Some("ValidationError"));
919        assert_eq!(err.api_message(), Some("Validation failed"));
920    }
921
922    // ── update_post ───────────────────────────────────────────────────────────
923
924    #[tokio::test]
925    async fn test_update_post_sends_envelope_with_updated_at() {
926        let server = MockServer::start().await;
927
928        let expected_body = serde_json::json!({
929            "posts": [{"updated_at": "2026-01-01T00:00:00.000Z"}]
930        });
931
932        Mock::given(method("PUT"))
933            .and(path("/ghost/api/admin/posts/abc123/"))
934            .and(header_exists("Authorization"))
935            .and(body_json(&expected_body))
936            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
937                "posts": [post_json("abc123", "Unchanged", "draft")]
938            })))
939            .expect(1)
940            .mount(&server)
941            .await;
942
943        make_client(&server.uri())
944            .update_post("abc123", PostUpdate::new("2026-01-01T00:00:00.000Z"))
945            .await
946            .unwrap();
947
948        server.verify().await;
949    }
950
951    #[tokio::test]
952    async fn test_update_post_returns_updated_post() {
953        let server = MockServer::start().await;
954
955        Mock::given(method("PUT"))
956            .and(path("/ghost/api/admin/posts/abc123/"))
957            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
958                "posts": [post_json("abc123", "Updated Title", "published")]
959            })))
960            .mount(&server)
961            .await;
962
963        use crate::models::post::PostStatus;
964        let post = make_client(&server.uri())
965            .update_post(
966                "abc123",
967                PostUpdate {
968                    updated_at: "2026-01-01T00:00:00.000Z".to_string(),
969                    title: Some("Updated Title".to_string()),
970                    status: Some(PostStatus::Published),
971                    ..Default::default()
972                },
973            )
974            .await
975            .unwrap();
976
977        assert_eq!(post.title, "Updated Title");
978    }
979
980    #[tokio::test]
981    async fn test_update_post_conflict_error() {
982        let server = MockServer::start().await;
983        Mock::given(method("PUT"))
984            .and(path("/ghost/api/admin/posts/abc123/"))
985            .respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
986                "errors": [{"message": "Conflict", "type": "ConflictError", "context": "updated_at is stale"}]
987            })))
988            .mount(&server)
989            .await;
990
991        let err = make_client(&server.uri())
992            .update_post("abc123", PostUpdate::new("2020-01-01T00:00:00.000Z"))
993            .await
994            .unwrap_err();
995
996        assert_eq!(err.api_error_type(), Some("ConflictError"));
997    }
998
999    #[tokio::test]
1000    async fn test_update_post_not_found() {
1001        let server = MockServer::start().await;
1002        Mock::given(method("PUT"))
1003            .and(path("/ghost/api/admin/posts/nope/"))
1004            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1005                "errors": [{"message": "Not found", "type": "NotFoundError", "context": null}]
1006            })))
1007            .mount(&server)
1008            .await;
1009
1010        let err = make_client(&server.uri())
1011            .update_post("nope", PostUpdate::new("2026-01-01T00:00:00.000Z"))
1012            .await
1013            .unwrap_err();
1014
1015        assert_eq!(err.api_error_type(), Some("NotFoundError"));
1016    }
1017
1018    // ── delete_post ───────────────────────────────────────────────────────────
1019
1020    #[tokio::test]
1021    async fn test_delete_post_success() {
1022        let server = MockServer::start().await;
1023        Mock::given(method("DELETE"))
1024            .and(path("/ghost/api/admin/posts/abc123/"))
1025            .and(header_exists("Authorization"))
1026            .respond_with(ResponseTemplate::new(204))
1027            .expect(1)
1028            .mount(&server)
1029            .await;
1030
1031        make_client(&server.uri())
1032            .delete_post("abc123")
1033            .await
1034            .unwrap();
1035
1036        server.verify().await;
1037    }
1038
1039    #[tokio::test]
1040    async fn test_delete_post_not_found() {
1041        let server = MockServer::start().await;
1042        Mock::given(method("DELETE"))
1043            .and(path("/ghost/api/admin/posts/nope/"))
1044            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1045                "errors": [{"message": "Post not found", "type": "NotFoundError", "context": null}]
1046            })))
1047            .mount(&server)
1048            .await;
1049
1050        let err = make_client(&server.uri())
1051            .delete_post("nope")
1052            .await
1053            .unwrap_err();
1054
1055        assert_eq!(err.api_error_type(), Some("NotFoundError"));
1056    }
1057
1058    // ── auth header (regression) ──────────────────────────────────────────────
1059
1060    #[test]
1061    fn test_auth_header_starts_with_ghost() {
1062        let client = GhostAdminClient::new("https://example.ghost.io", make_key()).unwrap();
1063        let hv = client.auth_header_value().unwrap();
1064        assert!(hv.to_str().unwrap().starts_with("Ghost "));
1065    }
1066
1067    #[test]
1068    fn test_admin_url_construction() {
1069        let client = GhostAdminClient::new("https://example.ghost.io", make_key()).unwrap();
1070        assert_eq!(
1071            client.admin_url("/posts/"),
1072            "https://example.ghost.io/ghost/api/admin/posts/"
1073        );
1074    }
1075}