Skip to main content

ghost_io_api/models/
post.rs

1//! Post model for Ghost API.
2//!
3//! Represents a post/article in Ghost with all its associated metadata,
4//! content, and relationships.
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::models::post::{Post, PostStatus};
10//!
11//! // Typically posts come from API responses
12//! let post = Post {
13//!     id: "5ddc9141c35e7700383b2937".to_string(),
14//!     uuid: Some("a5aa9bd8-ea31-415c-b452-3040dae1e730".to_string()),
15//!     title: "Welcome".to_string(),
16//!     slug: "welcome-short".to_string(),
17//!     status: PostStatus::Published,
18//!     visibility: Some("public".to_string()),
19//!     created_at: Some("2019-11-26T02:43:13.000Z".to_string()),
20//!     updated_at: Some("2019-11-26T02:44:17.000Z".to_string()),
21//!     published_at: Some("2019-11-26T02:44:17.000Z".to_string()),
22//!     // ... other fields
23//!     ..Default::default()
24//! };
25//!
26//! assert_eq!(post.status, PostStatus::Published);
27//! assert!(post.is_published());
28//! ```
29
30use serde::{Deserialize, Serialize};
31
32/// Status of a Ghost post.
33///
34/// Posts can be in one of four states throughout their lifecycle.
35///
36/// # Example
37///
38/// ```
39/// use ghost_io_api::models::post::PostStatus;
40/// use serde_json;
41///
42/// let json = serde_json::json!("published");
43/// let status: PostStatus = serde_json::from_value(json).unwrap();
44/// assert_eq!(status, PostStatus::Published);
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum PostStatus {
49    /// Post is a draft, not visible publicly.
50    #[default]
51    Draft,
52    /// Post is published and visible.
53    Published,
54    /// Post is scheduled for future publication.
55    Scheduled,
56    /// Post was sent as an email newsletter.
57    Sent,
58}
59
60impl PostStatus {
61    /// Returns `true` if the post is published.
62    ///
63    /// # Example
64    ///
65    /// ```
66    /// use ghost_io_api::models::post::PostStatus;
67    ///
68    /// assert!(PostStatus::Published.is_published());
69    /// assert!(!PostStatus::Draft.is_published());
70    /// ```
71    pub fn is_published(&self) -> bool {
72        matches!(self, PostStatus::Published)
73    }
74
75    /// Returns `true` if the post is a draft.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use ghost_io_api::models::post::PostStatus;
81    ///
82    /// assert!(PostStatus::Draft.is_draft());
83    /// assert!(!PostStatus::Published.is_draft());
84    /// ```
85    pub fn is_draft(&self) -> bool {
86        matches!(self, PostStatus::Draft)
87    }
88
89    /// Returns `true` if the post is scheduled.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use ghost_io_api::models::post::PostStatus;
95    ///
96    /// assert!(PostStatus::Scheduled.is_scheduled());
97    /// assert!(!PostStatus::Draft.is_scheduled());
98    /// ```
99    pub fn is_scheduled(&self) -> bool {
100        matches!(self, PostStatus::Scheduled)
101    }
102
103    /// Returns `true` if the post was sent as email.
104    ///
105    /// # Example
106    ///
107    /// ```
108    /// use ghost_io_api::models::post::PostStatus;
109    ///
110    /// assert!(PostStatus::Sent.is_sent());
111    /// assert!(!PostStatus::Draft.is_sent());
112    /// ```
113    pub fn is_sent(&self) -> bool {
114        matches!(self, PostStatus::Sent)
115    }
116}
117
118/// A Ghost post/article.
119///
120/// Represents a post with all its metadata, content, and relationships.
121/// Posts can contain Lexical JSON content or HTML, along with extensive
122/// metadata for SEO, social sharing, and organization.
123///
124/// # Required Fields
125///
126/// Only `title` is required when creating a new post. The Ghost API will
127/// generate IDs, slugs, and timestamps automatically.
128///
129/// # Field Details
130///
131/// * **Identifiers:** `id`, `uuid`, `slug`, `comment_id`
132/// * **Content:** `title`, `lexical`, `html`, `excerpt`, `custom_excerpt`
133/// * **Status:** `status`, `visibility`, `email_only`
134/// * **Timestamps:** `created_at`, `updated_at`, `published_at`
135/// * **Media:** `feature_image`, `feature_image_alt`, `feature_image_caption`
136/// * **Flags:** `featured`
137/// * **Relationships:** `tags`, `authors`, `primary_author`, `primary_tag`, `newsletter`
138/// * **SEO:** `meta_title`, `meta_description`, `canonical_url`, `url`
139/// * **Social:** `og_*` (Open Graph), `twitter_*` (Twitter Cards)
140/// * **Code Injection:** `codeinjection_head`, `codeinjection_foot`, `custom_template`
141///
142/// # Example
143///
144/// ```
145/// use ghost_io_api::models::post::{Post, PostStatus};
146/// use serde_json::json;
147///
148/// let json = json!({
149///     "id": "5ddc9141c35e7700383b2937",
150///     "title": "Welcome",
151///     "slug": "welcome-short",
152///     "status": "published",
153///     "visibility": "public",
154///     "created_at": "2019-11-26T02:43:13.000Z",
155///     "published_at": "2019-11-26T02:44:17.000Z"
156/// });
157///
158/// let post: Post = serde_json::from_value(json).unwrap();
159/// assert_eq!(post.title, "Welcome");
160/// assert_eq!(post.status, PostStatus::Published);
161/// assert!(post.is_published());
162/// ```
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
164pub struct Post {
165    // === Identifiers ===
166    /// Unique post ID (24-character hex string).
167    #[serde(skip_serializing_if = "String::is_empty", default)]
168    pub id: String,
169
170    /// UUID of the post (RFC 4122).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub uuid: Option<String>,
173
174    /// URL-friendly identifier (unique, auto-generated from title).
175    #[serde(skip_serializing_if = "String::is_empty", default)]
176    pub slug: String,
177
178    /// ID used for Disqus/comment systems (typically same as `id`).
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub comment_id: Option<String>,
181
182    // === Content ===
183    /// Post title (**required** on create).
184    pub title: String,
185
186    /// Rich text content in Lexical JSON format.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub lexical: Option<String>,
189
190    /// Rendered HTML content (read-only, use `?formats=html`).
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub html: Option<String>,
193
194    /// Auto-generated excerpt from content.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub excerpt: Option<String>,
197
198    /// Custom excerpt/summary (overrides auto-generated).
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub custom_excerpt: Option<String>,
201
202    // === Status & Visibility ===
203    /// Publication status: draft, published, scheduled, or sent.
204    #[serde(default)]
205    pub status: PostStatus,
206
207    /// Who can see the post: "public", "members", "paid", "tiers".
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub visibility: Option<String>,
210
211    /// If `true`, post is sent as email only (no web post).
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub email_only: Option<bool>,
214
215    // === Timestamps ===
216    /// When the post was created (ISO 8601).
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub created_at: Option<String>,
219
220    /// When the post was last updated (ISO 8601).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub updated_at: Option<String>,
223
224    /// When the post was/will be published (ISO 8601).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub published_at: Option<String>,
227
228    // === Media ===
229    /// URL to the feature image.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub feature_image: Option<String>,
232
233    /// Alt text for the feature image (accessibility).
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub feature_image_alt: Option<String>,
236
237    /// Caption for the feature image.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub feature_image_caption: Option<String>,
240
241    // === Flags ===
242    /// Whether this post is featured/highlighted.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub featured: Option<bool>,
245
246    // === Relationships (simplified) ===
247    // Note: Full tag/author/newsletter objects would require separate types.
248    // For now, we use serde_json::Value to accept any structure.
249    /// Associated tags (array of tag objects or strings).
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub tags: Option<serde_json::Value>,
252
253    /// Post authors (array of author objects or email strings).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub authors: Option<serde_json::Value>,
256
257    /// Primary author object.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub primary_author: Option<serde_json::Value>,
260
261    /// Primary tag object.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub primary_tag: Option<serde_json::Value>,
264
265    /// Newsletter this post belongs to.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub newsletter: Option<serde_json::Value>,
268
269    /// Email send metadata (for posts sent as emails).
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub email: Option<serde_json::Value>,
272
273    // === URLs ===
274    /// Public URL of the post (read-only).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub url: Option<String>,
277
278    /// Override canonical URL for SEO.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub canonical_url: Option<String>,
281
282    // === SEO ===
283    /// Custom meta title for `<title>` tag.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub meta_title: Option<String>,
286
287    /// Custom meta description for `<meta name="description">`.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub meta_description: Option<String>,
290
291    // === Open Graph (Facebook) ===
292    /// Custom Open Graph image URL.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub og_image: Option<String>,
295
296    /// Custom Open Graph title.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub og_title: Option<String>,
299
300    /// Custom Open Graph description.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub og_description: Option<String>,
303
304    // === Twitter Cards ===
305    /// Custom Twitter Card image URL.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub twitter_image: Option<String>,
308
309    /// Custom Twitter Card title.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub twitter_title: Option<String>,
312
313    /// Custom Twitter Card description.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub twitter_description: Option<String>,
316
317    // === Code Injection ===
318    /// Custom HTML injected into `<head>` (Ghost Admin only).
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub codeinjection_head: Option<String>,
321
322    /// Custom HTML injected before `</body>` (Ghost Admin only).
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub codeinjection_foot: Option<String>,
325
326    /// Custom theme template name to use for rendering.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub custom_template: Option<String>,
329}
330
331impl Post {
332    /// Returns `true` if the post is published.
333    ///
334    /// # Example
335    ///
336    /// ```
337    /// use ghost_io_api::models::post::{Post, PostStatus};
338    ///
339    /// let mut post = Post::default();
340    /// post.status = PostStatus::Published;
341    /// assert!(post.is_published());
342    /// ```
343    pub fn is_published(&self) -> bool {
344        self.status.is_published()
345    }
346
347    /// Returns `true` if the post is a draft.
348    ///
349    /// # Example
350    ///
351    /// ```
352    /// use ghost_io_api::models::post::{Post, PostStatus};
353    ///
354    /// let mut post = Post::default();
355    /// post.status = PostStatus::Draft;
356    /// assert!(post.is_draft());
357    /// ```
358    pub fn is_draft(&self) -> bool {
359        self.status.is_draft()
360    }
361
362    /// Returns `true` if the post is scheduled for future publication.
363    ///
364    /// # Example
365    ///
366    /// ```
367    /// use ghost_io_api::models::post::{Post, PostStatus};
368    ///
369    /// let mut post = Post::default();
370    /// post.status = PostStatus::Scheduled;
371    /// assert!(post.is_scheduled());
372    /// ```
373    pub fn is_scheduled(&self) -> bool {
374        self.status.is_scheduled()
375    }
376
377    /// Returns `true` if the post was sent as an email newsletter.
378    ///
379    /// # Example
380    ///
381    /// ```
382    /// use ghost_io_api::models::post::{Post, PostStatus};
383    ///
384    /// let mut post = Post::default();
385    /// post.status = PostStatus::Sent;
386    /// assert!(post.is_sent());
387    /// ```
388    pub fn is_sent(&self) -> bool {
389        self.status.is_sent()
390    }
391
392    /// Returns `true` if the post is featured.
393    ///
394    /// # Example
395    ///
396    /// ```
397    /// use ghost_io_api::models::post::Post;
398    ///
399    /// let mut post = Post::default();
400    /// post.featured = Some(true);
401    /// assert!(post.is_featured());
402    /// ```
403    pub fn is_featured(&self) -> bool {
404        self.featured.unwrap_or(false)
405    }
406
407    /// Returns `true` if the post is email-only (no web post).
408    ///
409    /// # Example
410    ///
411    /// ```
412    /// use ghost_io_api::models::post::Post;
413    ///
414    /// let mut post = Post::default();
415    /// post.email_only = Some(true);
416    /// assert!(post.is_email_only());
417    /// ```
418    pub fn is_email_only(&self) -> bool {
419        self.email_only.unwrap_or(false)
420    }
421}
422
423// ── PostCreate supporting types ───────────────────────────────────────────────
424
425/// Reference to a tag by name or slug, used when creating/updating posts.
426///
427/// At least one of `name` or `slug` must be set. Ghost resolves the tag by
428/// slug first; if not found it creates a new tag with the given name.
429///
430/// # Example
431///
432/// ```
433/// use ghost_io_api::models::post::TagRef;
434///
435/// // Reference an existing tag by slug
436/// let existing = TagRef { slug: Some("rust".to_string()), ..Default::default() };
437///
438/// // Create a new tag on the fly
439/// let new_tag = TagRef { name: Some("WebAssembly".to_string()), ..Default::default() };
440/// ```
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
442pub struct TagRef {
443    /// Tag name (used to create the tag if it does not exist yet).
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub name: Option<String>,
446    /// Tag slug (used to look up an existing tag).
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub slug: Option<String>,
449}
450
451impl TagRef {
452    /// Creates a `TagRef` that looks up an existing tag by slug.
453    ///
454    /// # Example
455    ///
456    /// ```
457    /// use ghost_io_api::models::post::TagRef;
458    ///
459    /// let t = TagRef::by_slug("rust");
460    /// assert_eq!(t.slug.as_deref(), Some("rust"));
461    /// ```
462    pub fn by_slug(slug: impl Into<String>) -> Self {
463        Self {
464            slug: Some(slug.into()),
465            name: None,
466        }
467    }
468
469    /// Creates a `TagRef` that creates a new tag with the given name.
470    ///
471    /// # Example
472    ///
473    /// ```
474    /// use ghost_io_api::models::post::TagRef;
475    ///
476    /// let t = TagRef::by_name("WebAssembly");
477    /// assert_eq!(t.name.as_deref(), Some("WebAssembly"));
478    /// ```
479    pub fn by_name(name: impl Into<String>) -> Self {
480        Self {
481            name: Some(name.into()),
482            slug: None,
483        }
484    }
485}
486
487/// Reference to an author by id or email, used when creating/updating posts.
488///
489/// # Example
490///
491/// ```
492/// use ghost_io_api::models::post::AuthorRef;
493///
494/// let author = AuthorRef { email: Some("jane@example.com".to_string()), ..Default::default() };
495/// ```
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
497pub struct AuthorRef {
498    /// Ghost user ID.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub id: Option<String>,
501    /// Ghost user email address.
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub email: Option<String>,
504}
505
506impl AuthorRef {
507    /// Creates an `AuthorRef` that identifies an author by their Ghost ID.
508    ///
509    /// # Example
510    ///
511    /// ```
512    /// use ghost_io_api::models::post::AuthorRef;
513    ///
514    /// let a = AuthorRef::by_id("6748592f4b9b7700010f6564");
515    /// assert_eq!(a.id.as_deref(), Some("6748592f4b9b7700010f6564"));
516    /// ```
517    pub fn by_id(id: impl Into<String>) -> Self {
518        Self {
519            id: Some(id.into()),
520            email: None,
521        }
522    }
523
524    /// Creates an `AuthorRef` that identifies an author by their email.
525    ///
526    /// # Example
527    ///
528    /// ```
529    /// use ghost_io_api::models::post::AuthorRef;
530    ///
531    /// let a = AuthorRef::by_email("jane@example.com");
532    /// assert_eq!(a.email.as_deref(), Some("jane@example.com"));
533    /// ```
534    pub fn by_email(email: impl Into<String>) -> Self {
535        Self {
536            email: Some(email.into()),
537            id: None,
538        }
539    }
540}
541
542// ── PostCreate ────────────────────────────────────────────────────────────────
543
544/// Input model for creating a new Ghost post via `POST /ghost/api/admin/posts/`.
545///
546/// Only `title` is required by Ghost; every other field is optional and omitted
547/// from the JSON payload when not set (via `#[serde(skip_serializing_if)]`).
548///
549/// Wrap this in a [`PostCreateEnvelope`] before sending to the API.
550///
551/// # Example
552///
553/// ```
554/// use ghost_io_api::models::post::{PostCreate, PostStatus, TagRef};
555///
556/// let post = PostCreate {
557///     title: "Hello, Ghost!".to_string(),
558///     status: Some(PostStatus::Published),
559///     tags: Some(vec![TagRef::by_slug("rust")]),
560///     ..Default::default()
561/// };
562///
563/// assert_eq!(post.title, "Hello, Ghost!");
564/// assert_eq!(post.status, Some(PostStatus::Published));
565/// ```
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
567pub struct PostCreate {
568    // === Required ===
569    /// Post title (**required**).
570    pub title: String,
571
572    // === Content ===
573    /// Rich text content in Lexical JSON format (Ghost 6+).
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub lexical: Option<String>,
576
577    /// HTML content (alternative to `lexical` for simpler content).
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub html: Option<String>,
580
581    /// Custom excerpt shown in post listings (overrides auto-generated).
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub custom_excerpt: Option<String>,
584
585    // === Identifiers ===
586    /// URL slug (auto-generated from `title` if not set).
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub slug: Option<String>,
589
590    // === Status & Visibility ===
591    /// Publication status. Defaults to `Draft` when omitted.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub status: Option<PostStatus>,
594
595    /// Visibility of the post: `"public"`, `"members"`, `"paid"`, or `"tiers"`.
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub visibility: Option<String>,
598
599    /// If `true`, the post is delivered as email only (no web post).
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub email_only: Option<bool>,
602
603    /// If `true`, the post is highlighted in the portal.
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub featured: Option<bool>,
606
607    // === Scheduling ===
608    /// ISO 8601 publication timestamp. Required when `status` is `"scheduled"`.
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub published_at: Option<String>,
611
612    // === Media ===
613    /// URL to the feature image.
614    #[serde(skip_serializing_if = "Option::is_none")]
615    pub feature_image: Option<String>,
616
617    /// Alt text for the feature image (accessibility).
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub feature_image_alt: Option<String>,
620
621    /// Caption displayed beneath the feature image.
622    #[serde(skip_serializing_if = "Option::is_none")]
623    pub feature_image_caption: Option<String>,
624
625    // === Relationships ===
626    /// Tags to associate with the post. Ghost resolves by slug or creates by name.
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub tags: Option<Vec<TagRef>>,
629
630    /// Authors to attribute the post to. Resolves by id or email.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub authors: Option<Vec<AuthorRef>>,
633
634    /// Slug of the newsletter to associate this post with.
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub newsletter: Option<String>,
637
638    // === SEO ===
639    /// Custom `<title>` tag content for SEO.
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub meta_title: Option<String>,
642
643    /// Custom `<meta name="description">` content for SEO.
644    #[serde(skip_serializing_if = "Option::is_none")]
645    pub meta_description: Option<String>,
646
647    /// Canonical URL override for SEO (prevents duplicate-content penalties).
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub canonical_url: Option<String>,
650
651    // === Open Graph ===
652    /// Custom Open Graph image URL.
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub og_image: Option<String>,
655
656    /// Custom Open Graph title.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub og_title: Option<String>,
659
660    /// Custom Open Graph description.
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub og_description: Option<String>,
663
664    // === Twitter Cards ===
665    /// Custom Twitter Card image URL.
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub twitter_image: Option<String>,
668
669    /// Custom Twitter Card title.
670    #[serde(skip_serializing_if = "Option::is_none")]
671    pub twitter_title: Option<String>,
672
673    /// Custom Twitter Card description.
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub twitter_description: Option<String>,
676
677    // === Code Injection ===
678    /// Custom HTML injected into `<head>` for this post.
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub codeinjection_head: Option<String>,
681
682    /// Custom HTML injected before `</body>` for this post.
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub codeinjection_foot: Option<String>,
685
686    /// Custom theme template name to use when rendering this post.
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub custom_template: Option<String>,
689}
690
691impl PostCreate {
692    /// Creates a minimal `PostCreate` with only the required `title`.
693    ///
694    /// All other fields default to `None` and will be omitted from the
695    /// serialised JSON, letting Ghost apply its own defaults.
696    ///
697    /// # Example
698    ///
699    /// ```
700    /// use ghost_io_api::models::post::PostCreate;
701    ///
702    /// let post = PostCreate::new("Hello, World!");
703    /// assert_eq!(post.title, "Hello, World!");
704    /// assert!(post.status.is_none());
705    /// assert!(post.tags.is_none());
706    /// ```
707    pub fn new(title: impl Into<String>) -> Self {
708        Self {
709            title: title.into(),
710            ..Default::default()
711        }
712    }
713}
714
715/// Request envelope for `POST /ghost/api/admin/posts/`.
716///
717/// The Ghost Admin API requires the post payload to be wrapped in a
718/// top-level `posts` array even when creating a single post.
719///
720/// # Example
721///
722/// ```
723/// use ghost_io_api::models::post::{PostCreate, PostCreateEnvelope};
724/// use serde_json;
725///
726/// let envelope = PostCreateEnvelope::new(PostCreate::new("My Post"));
727/// let json = serde_json::to_value(&envelope).unwrap();
728/// assert!(json["posts"].is_array());
729/// assert_eq!(json["posts"][0]["title"], "My Post");
730/// ```
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct PostCreateEnvelope {
733    /// Single-element array containing the post to create.
734    pub posts: Vec<PostCreate>,
735}
736
737impl PostCreateEnvelope {
738    /// Wraps a single `PostCreate` in the envelope Ghost expects.
739    ///
740    /// # Example
741    ///
742    /// ```
743    /// use ghost_io_api::models::post::{PostCreate, PostCreateEnvelope};
744    ///
745    /// let envelope = PostCreateEnvelope::new(PostCreate::new("Draft"));
746    /// assert_eq!(envelope.posts.len(), 1);
747    /// assert_eq!(envelope.posts[0].title, "Draft");
748    /// ```
749    pub fn new(post: PostCreate) -> Self {
750        Self { posts: vec![post] }
751    }
752}
753
754// ── PostUpdate ────────────────────────────────────────────────────────────────
755
756/// Input model for updating an existing Ghost post via
757/// `PUT /ghost/api/admin/posts/{id}/`.
758///
759/// `updated_at` is **required** by Ghost as an optimistic-concurrency token:
760/// if the value you send does not match the server's current `updated_at`,
761/// Ghost rejects the request with a conflict error, preventing silent
762/// overwrite of concurrent edits.
763///
764/// Every other field is optional; only the fields you set will be sent in the
765/// JSON payload.  Wrap this in a [`PostUpdateEnvelope`] before sending.
766///
767/// # Example
768///
769/// ```
770/// use ghost_io_api::models::post::{PostUpdate, PostStatus, TagRef};
771///
772/// let update = PostUpdate {
773///     updated_at: "2026-01-01T00:00:00.000Z".to_string(),
774///     status: Some(PostStatus::Published),
775///     tags: Some(vec![TagRef::by_slug("rust")]),
776///     ..Default::default()
777/// };
778///
779/// assert_eq!(update.updated_at, "2026-01-01T00:00:00.000Z");
780/// assert_eq!(update.status, Some(PostStatus::Published));
781/// ```
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
783pub struct PostUpdate {
784    // === Collision-detection (required) ===
785    /// ISO 8601 timestamp of the post's last update.
786    ///
787    /// Ghost rejects the request if this does not match the server's
788    /// current value, preventing silent overwrites of concurrent edits.
789    pub updated_at: String,
790
791    // === Content ===
792    /// Updated post title.
793    #[serde(skip_serializing_if = "Option::is_none")]
794    pub title: Option<String>,
795
796    /// Updated rich text content in Lexical JSON format.
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub lexical: Option<String>,
799
800    /// Updated HTML content (alternative to `lexical`).
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub html: Option<String>,
803
804    /// Updated custom excerpt shown in post listings.
805    #[serde(skip_serializing_if = "Option::is_none")]
806    pub custom_excerpt: Option<String>,
807
808    // === Identifiers ===
809    /// Updated URL slug.
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub slug: Option<String>,
812
813    // === Status & Visibility ===
814    /// Updated publication status.
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub status: Option<PostStatus>,
817
818    /// Updated visibility: `"public"`, `"members"`, `"paid"`, or `"tiers"`.
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub visibility: Option<String>,
821
822    /// Updated email-only flag.
823    #[serde(skip_serializing_if = "Option::is_none")]
824    pub email_only: Option<bool>,
825
826    /// Updated featured flag.
827    #[serde(skip_serializing_if = "Option::is_none")]
828    pub featured: Option<bool>,
829
830    // === Scheduling ===
831    /// Updated publication timestamp (ISO 8601). Required when setting
832    /// `status` to `"scheduled"`.
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub published_at: Option<String>,
835
836    // === Media ===
837    /// Updated feature image URL.
838    #[serde(skip_serializing_if = "Option::is_none")]
839    pub feature_image: Option<String>,
840
841    /// Updated feature image alt text.
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub feature_image_alt: Option<String>,
844
845    /// Updated feature image caption.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub feature_image_caption: Option<String>,
848
849    // === Relationships ===
850    /// Replacement tag list. Resolves by slug or creates by name.
851    #[serde(skip_serializing_if = "Option::is_none")]
852    pub tags: Option<Vec<TagRef>>,
853
854    /// Replacement author list. Resolves by id or email.
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub authors: Option<Vec<AuthorRef>>,
857
858    /// Updated newsletter slug.
859    #[serde(skip_serializing_if = "Option::is_none")]
860    pub newsletter: Option<String>,
861
862    // === SEO ===
863    /// Updated meta title.
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub meta_title: Option<String>,
866
867    /// Updated meta description.
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub meta_description: Option<String>,
870
871    /// Updated canonical URL.
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub canonical_url: Option<String>,
874
875    // === Open Graph ===
876    /// Updated Open Graph image URL.
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub og_image: Option<String>,
879
880    /// Updated Open Graph title.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub og_title: Option<String>,
883
884    /// Updated Open Graph description.
885    #[serde(skip_serializing_if = "Option::is_none")]
886    pub og_description: Option<String>,
887
888    // === Twitter Cards ===
889    /// Updated Twitter Card image URL.
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub twitter_image: Option<String>,
892
893    /// Updated Twitter Card title.
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub twitter_title: Option<String>,
896
897    /// Updated Twitter Card description.
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub twitter_description: Option<String>,
900
901    // === Code Injection ===
902    /// Updated `<head>` code injection.
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub codeinjection_head: Option<String>,
905
906    /// Updated `</body>` code injection.
907    #[serde(skip_serializing_if = "Option::is_none")]
908    pub codeinjection_foot: Option<String>,
909
910    /// Updated custom theme template name.
911    #[serde(skip_serializing_if = "Option::is_none")]
912    pub custom_template: Option<String>,
913}
914
915impl PostUpdate {
916    /// Creates a minimal `PostUpdate` with only the required `updated_at`.
917    ///
918    /// All other fields default to `None` and are omitted from the JSON
919    /// payload, so only the fields you explicitly set will be sent.
920    ///
921    /// # Example
922    ///
923    /// ```
924    /// use ghost_io_api::models::post::PostUpdate;
925    ///
926    /// let u = PostUpdate::new("2026-01-01T12:00:00.000Z");
927    /// assert_eq!(u.updated_at, "2026-01-01T12:00:00.000Z");
928    /// assert!(u.title.is_none());
929    /// assert!(u.status.is_none());
930    /// ```
931    pub fn new(updated_at: impl Into<String>) -> Self {
932        Self {
933            updated_at: updated_at.into(),
934            ..Default::default()
935        }
936    }
937}
938
939/// Request envelope for `PUT /ghost/api/admin/posts/{id}/`.
940///
941/// The Ghost Admin API requires the update payload to be wrapped in a
942/// top-level `posts` array even when updating a single post.
943///
944/// # Example
945///
946/// ```
947/// use ghost_io_api::models::post::{PostUpdate, PostUpdateEnvelope};
948/// use serde_json;
949///
950/// let envelope = PostUpdateEnvelope::new(PostUpdate::new("2026-01-01T12:00:00.000Z"));
951/// let json = serde_json::to_value(&envelope).unwrap();
952/// assert!(json["posts"].is_array());
953/// assert_eq!(json["posts"][0]["updated_at"], "2026-01-01T12:00:00.000Z");
954/// ```
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct PostUpdateEnvelope {
957    /// Single-element array containing the post update payload.
958    pub posts: Vec<PostUpdate>,
959}
960
961impl PostUpdateEnvelope {
962    /// Wraps a single [`PostUpdate`] in the envelope Ghost expects.
963    ///
964    /// # Example
965    ///
966    /// ```
967    /// use ghost_io_api::models::post::{PostUpdate, PostUpdateEnvelope};
968    ///
969    /// let envelope = PostUpdateEnvelope::new(PostUpdate::new("2026-01-01T12:00:00.000Z"));
970    /// assert_eq!(envelope.posts.len(), 1);
971    /// assert_eq!(envelope.posts[0].updated_at, "2026-01-01T12:00:00.000Z");
972    /// ```
973    pub fn new(post: PostUpdate) -> Self {
974        Self { posts: vec![post] }
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use serde_json::json;
982
983    #[test]
984    fn test_post_status_deserialization() {
985        assert_eq!(
986            serde_json::from_str::<PostStatus>("\"draft\"").unwrap(),
987            PostStatus::Draft
988        );
989        assert_eq!(
990            serde_json::from_str::<PostStatus>("\"published\"").unwrap(),
991            PostStatus::Published
992        );
993        assert_eq!(
994            serde_json::from_str::<PostStatus>("\"scheduled\"").unwrap(),
995            PostStatus::Scheduled
996        );
997        assert_eq!(
998            serde_json::from_str::<PostStatus>("\"sent\"").unwrap(),
999            PostStatus::Sent
1000        );
1001    }
1002
1003    #[test]
1004    fn test_post_status_serialization() {
1005        assert_eq!(
1006            serde_json::to_string(&PostStatus::Draft).unwrap(),
1007            "\"draft\""
1008        );
1009        assert_eq!(
1010            serde_json::to_string(&PostStatus::Published).unwrap(),
1011            "\"published\""
1012        );
1013        assert_eq!(
1014            serde_json::to_string(&PostStatus::Scheduled).unwrap(),
1015            "\"scheduled\""
1016        );
1017        assert_eq!(
1018            serde_json::to_string(&PostStatus::Sent).unwrap(),
1019            "\"sent\""
1020        );
1021    }
1022
1023    #[test]
1024    fn test_post_status_methods() {
1025        assert!(PostStatus::Draft.is_draft());
1026        assert!(!PostStatus::Draft.is_published());
1027        assert!(!PostStatus::Draft.is_scheduled());
1028        assert!(!PostStatus::Draft.is_sent());
1029
1030        assert!(!PostStatus::Published.is_draft());
1031        assert!(PostStatus::Published.is_published());
1032        assert!(!PostStatus::Published.is_scheduled());
1033        assert!(!PostStatus::Published.is_sent());
1034
1035        assert!(!PostStatus::Scheduled.is_draft());
1036        assert!(!PostStatus::Scheduled.is_published());
1037        assert!(PostStatus::Scheduled.is_scheduled());
1038        assert!(!PostStatus::Scheduled.is_sent());
1039
1040        assert!(!PostStatus::Sent.is_draft());
1041        assert!(!PostStatus::Sent.is_published());
1042        assert!(!PostStatus::Sent.is_scheduled());
1043        assert!(PostStatus::Sent.is_sent());
1044    }
1045
1046    #[test]
1047    fn test_post_minimal_deserialization() {
1048        let json = json!({
1049            "id": "5ddc9141c35e7700383b2937",
1050            "title": "Welcome",
1051            "slug": "welcome-short",
1052            "status": "published"
1053        });
1054
1055        let post: Post = serde_json::from_value(json).unwrap();
1056        assert_eq!(post.id, "5ddc9141c35e7700383b2937");
1057        assert_eq!(post.title, "Welcome");
1058        assert_eq!(post.slug, "welcome-short");
1059        assert_eq!(post.status, PostStatus::Published);
1060    }
1061
1062    #[test]
1063    fn test_post_full_deserialization() {
1064        let json = json!({
1065            "id": "5ddc9141c35e7700383b2937",
1066            "uuid": "a5aa9bd8-ea31-415c-b452-3040dae1e730",
1067            "title": "Welcome",
1068            "slug": "welcome-short",
1069            "status": "published",
1070            "visibility": "public",
1071            "created_at": "2019-11-26T02:43:13.000Z",
1072            "updated_at": "2019-11-26T02:44:17.000Z",
1073            "published_at": "2019-11-26T02:44:17.000Z",
1074            "feature_image": "https://example.com/image.png",
1075            "featured": true,
1076            "excerpt": "Welcome excerpt",
1077            "custom_excerpt": "Custom excerpt",
1078            "meta_title": "Welcome | My Site",
1079            "meta_description": "A welcoming post",
1080            "og_title": "Welcome",
1081            "og_description": "OG description",
1082            "twitter_title": "Welcome on Twitter",
1083            "email_only": false,
1084            "canonical_url": "https://example.com/welcome"
1085        });
1086
1087        let post: Post = serde_json::from_value(json).unwrap();
1088        assert_eq!(post.id, "5ddc9141c35e7700383b2937");
1089        assert_eq!(
1090            post.uuid,
1091            Some("a5aa9bd8-ea31-415c-b452-3040dae1e730".to_string())
1092        );
1093        assert_eq!(post.title, "Welcome");
1094        assert_eq!(post.status, PostStatus::Published);
1095        assert_eq!(post.visibility, Some("public".to_string()));
1096        assert_eq!(post.featured, Some(true));
1097        assert_eq!(post.email_only, Some(false));
1098        assert_eq!(post.meta_title, Some("Welcome | My Site".to_string()));
1099    }
1100
1101    #[test]
1102    fn test_post_with_relationships() {
1103        let json = json!({
1104            "id": "123",
1105            "title": "Post with tags",
1106            "slug": "post-with-tags",
1107            "status": "draft",
1108            "tags": [
1109                {"id": "tag1", "name": "Tech"},
1110                {"id": "tag2", "name": "Rust"}
1111            ],
1112            "authors": [
1113                {"id": "author1", "name": "John Doe", "email": "john@example.com"}
1114            ]
1115        });
1116
1117        let post: Post = serde_json::from_value(json).unwrap();
1118        assert_eq!(post.title, "Post with tags");
1119        assert!(post.tags.is_some());
1120        assert!(post.authors.is_some());
1121    }
1122
1123    #[test]
1124    fn test_post_serialization() {
1125        let post = Post {
1126            id: "123".to_string(),
1127            title: "Test Post".to_string(),
1128            slug: "test-post".to_string(),
1129            status: PostStatus::Published,
1130            visibility: Some("public".to_string()),
1131            featured: Some(true),
1132            ..Default::default()
1133        };
1134
1135        let json = serde_json::to_value(&post).unwrap();
1136        assert_eq!(json["id"], "123");
1137        assert_eq!(json["title"], "Test Post");
1138        assert_eq!(json["slug"], "test-post");
1139        assert_eq!(json["status"], "published");
1140        assert_eq!(json["visibility"], "public");
1141        assert_eq!(json["featured"], true);
1142    }
1143
1144    #[test]
1145    fn test_post_serialization_skips_none() {
1146        let post = Post {
1147            id: "123".to_string(),
1148            title: "Minimal Post".to_string(),
1149            slug: "minimal".to_string(),
1150            status: PostStatus::Draft,
1151            ..Default::default()
1152        };
1153
1154        let json = serde_json::to_value(&post).unwrap();
1155        assert!(!json.as_object().unwrap().contains_key("uuid"));
1156        assert!(!json.as_object().unwrap().contains_key("feature_image"));
1157        assert!(!json.as_object().unwrap().contains_key("tags"));
1158    }
1159
1160    #[test]
1161    fn test_post_default() {
1162        let post = Post::default();
1163        assert_eq!(post.id, "");
1164        assert_eq!(post.title, "");
1165        assert_eq!(post.slug, "");
1166        assert_eq!(post.status, PostStatus::Draft);
1167        assert_eq!(post.uuid, None);
1168        assert_eq!(post.featured, None);
1169    }
1170
1171    #[test]
1172    fn test_post_is_published() {
1173        let post = Post {
1174            status: PostStatus::Published,
1175            ..Default::default()
1176        };
1177        assert!(post.is_published());
1178        assert!(!post.is_draft());
1179    }
1180
1181    #[test]
1182    fn test_post_is_draft() {
1183        let post = Post {
1184            status: PostStatus::Draft,
1185            ..Default::default()
1186        };
1187        assert!(post.is_draft());
1188        assert!(!post.is_published());
1189    }
1190
1191    #[test]
1192    fn test_post_is_scheduled() {
1193        let post = Post {
1194            status: PostStatus::Scheduled,
1195            ..Default::default()
1196        };
1197        assert!(post.is_scheduled());
1198        assert!(!post.is_published());
1199    }
1200
1201    #[test]
1202    fn test_post_is_sent() {
1203        let post = Post {
1204            status: PostStatus::Sent,
1205            ..Default::default()
1206        };
1207        assert!(post.is_sent());
1208        assert!(!post.is_published());
1209    }
1210
1211    #[test]
1212    fn test_post_is_featured() {
1213        let mut post = Post::default();
1214        assert!(!post.is_featured());
1215
1216        post.featured = Some(true);
1217        assert!(post.is_featured());
1218
1219        post.featured = Some(false);
1220        assert!(!post.is_featured());
1221    }
1222
1223    #[test]
1224    fn test_post_is_email_only() {
1225        let mut post = Post::default();
1226        assert!(!post.is_email_only());
1227
1228        post.email_only = Some(true);
1229        assert!(post.is_email_only());
1230
1231        post.email_only = Some(false);
1232        assert!(!post.is_email_only());
1233    }
1234
1235    #[test]
1236    fn test_post_clone() {
1237        let post = Post {
1238            id: "123".to_string(),
1239            title: "Clone Test".to_string(),
1240            slug: "clone-test".to_string(),
1241            status: PostStatus::Published,
1242            ..Default::default()
1243        };
1244
1245        let cloned = post.clone();
1246        assert_eq!(post, cloned);
1247    }
1248
1249    #[test]
1250    fn test_post_status_clone() {
1251        let status = PostStatus::Published;
1252        let cloned = status;
1253        assert_eq!(status, cloned);
1254    }
1255
1256    // ── TagRef ────────────────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn test_tag_ref_by_slug() {
1260        let t = TagRef::by_slug("rust");
1261        assert_eq!(t.slug.as_deref(), Some("rust"));
1262        assert!(t.name.is_none());
1263    }
1264
1265    #[test]
1266    fn test_tag_ref_by_name() {
1267        let t = TagRef::by_name("WebAssembly");
1268        assert_eq!(t.name.as_deref(), Some("WebAssembly"));
1269        assert!(t.slug.is_none());
1270    }
1271
1272    #[test]
1273    fn test_tag_ref_default() {
1274        let t = TagRef::default();
1275        assert!(t.name.is_none());
1276        assert!(t.slug.is_none());
1277    }
1278
1279    #[test]
1280    fn test_tag_ref_serialization_skips_none() {
1281        let t = TagRef::by_slug("rust");
1282        let json = serde_json::to_value(&t).unwrap();
1283        assert_eq!(json["slug"], "rust");
1284        assert!(!json.as_object().unwrap().contains_key("name"));
1285    }
1286
1287    #[test]
1288    fn test_tag_ref_deserialization() {
1289        let json = json!({"name": "Rust", "slug": "rust"});
1290        let t: TagRef = serde_json::from_value(json).unwrap();
1291        assert_eq!(t.name.as_deref(), Some("Rust"));
1292        assert_eq!(t.slug.as_deref(), Some("rust"));
1293    }
1294
1295    #[test]
1296    fn test_tag_ref_clone_eq() {
1297        let t = TagRef::by_slug("rust");
1298        assert_eq!(t.clone(), t);
1299    }
1300
1301    // ── AuthorRef ─────────────────────────────────────────────────────────────
1302
1303    #[test]
1304    fn test_author_ref_by_id() {
1305        let a = AuthorRef::by_id("abc123");
1306        assert_eq!(a.id.as_deref(), Some("abc123"));
1307        assert!(a.email.is_none());
1308    }
1309
1310    #[test]
1311    fn test_author_ref_by_email() {
1312        let a = AuthorRef::by_email("jane@example.com");
1313        assert_eq!(a.email.as_deref(), Some("jane@example.com"));
1314        assert!(a.id.is_none());
1315    }
1316
1317    #[test]
1318    fn test_author_ref_default() {
1319        let a = AuthorRef::default();
1320        assert!(a.id.is_none());
1321        assert!(a.email.is_none());
1322    }
1323
1324    #[test]
1325    fn test_author_ref_serialization_skips_none() {
1326        let a = AuthorRef::by_email("jane@example.com");
1327        let json = serde_json::to_value(&a).unwrap();
1328        assert_eq!(json["email"], "jane@example.com");
1329        assert!(!json.as_object().unwrap().contains_key("id"));
1330    }
1331
1332    #[test]
1333    fn test_author_ref_deserialization() {
1334        let json = json!({"id": "abc123", "email": "jane@example.com"});
1335        let a: AuthorRef = serde_json::from_value(json).unwrap();
1336        assert_eq!(a.id.as_deref(), Some("abc123"));
1337        assert_eq!(a.email.as_deref(), Some("jane@example.com"));
1338    }
1339
1340    #[test]
1341    fn test_author_ref_clone_eq() {
1342        let a = AuthorRef::by_email("jane@example.com");
1343        assert_eq!(a.clone(), a);
1344    }
1345
1346    // ── PostCreate ────────────────────────────────────────────────────────────
1347
1348    #[test]
1349    fn test_post_create_new_title_only() {
1350        let p = PostCreate::new("Hello, Ghost!");
1351        assert_eq!(p.title, "Hello, Ghost!");
1352        assert!(p.status.is_none());
1353        assert!(p.slug.is_none());
1354        assert!(p.lexical.is_none());
1355        assert!(p.tags.is_none());
1356        assert!(p.authors.is_none());
1357    }
1358
1359    #[test]
1360    fn test_post_create_default_all_none() {
1361        let p = PostCreate::default();
1362        assert_eq!(p.title, "");
1363        assert!(p.status.is_none());
1364        assert!(p.visibility.is_none());
1365        assert!(p.featured.is_none());
1366        assert!(p.email_only.is_none());
1367        assert!(p.published_at.is_none());
1368        assert!(p.lexical.is_none());
1369        assert!(p.html.is_none());
1370        assert!(p.custom_excerpt.is_none());
1371        assert!(p.slug.is_none());
1372        assert!(p.feature_image.is_none());
1373        assert!(p.tags.is_none());
1374        assert!(p.authors.is_none());
1375        assert!(p.newsletter.is_none());
1376        assert!(p.meta_title.is_none());
1377        assert!(p.meta_description.is_none());
1378        assert!(p.canonical_url.is_none());
1379        assert!(p.og_image.is_none());
1380        assert!(p.og_title.is_none());
1381        assert!(p.og_description.is_none());
1382        assert!(p.twitter_image.is_none());
1383        assert!(p.twitter_title.is_none());
1384        assert!(p.twitter_description.is_none());
1385        assert!(p.codeinjection_head.is_none());
1386        assert!(p.codeinjection_foot.is_none());
1387        assert!(p.custom_template.is_none());
1388    }
1389
1390    #[test]
1391    fn test_post_create_serializes_title_only() {
1392        let p = PostCreate::new("Minimal");
1393        let json = serde_json::to_value(&p).unwrap();
1394        let obj = json.as_object().unwrap();
1395        assert_eq!(json["title"], "Minimal");
1396        // All optional fields must be absent
1397        for key in &[
1398            "status",
1399            "slug",
1400            "lexical",
1401            "html",
1402            "custom_excerpt",
1403            "visibility",
1404            "featured",
1405            "email_only",
1406            "published_at",
1407            "feature_image",
1408            "feature_image_alt",
1409            "feature_image_caption",
1410            "tags",
1411            "authors",
1412            "newsletter",
1413            "meta_title",
1414            "meta_description",
1415            "canonical_url",
1416            "og_image",
1417            "og_title",
1418            "og_description",
1419            "twitter_image",
1420            "twitter_title",
1421            "twitter_description",
1422            "codeinjection_head",
1423            "codeinjection_foot",
1424            "custom_template",
1425        ] {
1426            assert!(!obj.contains_key(*key), "unexpected key: {key}");
1427        }
1428    }
1429
1430    #[test]
1431    fn test_post_create_with_status() {
1432        let p = PostCreate {
1433            title: "Draft Post".to_string(),
1434            status: Some(PostStatus::Draft),
1435            ..Default::default()
1436        };
1437        let json = serde_json::to_value(&p).unwrap();
1438        assert_eq!(json["status"], "draft");
1439    }
1440
1441    #[test]
1442    fn test_post_create_with_all_fields() {
1443        let p = PostCreate {
1444            title: "Full Post".to_string(),
1445            status: Some(PostStatus::Published),
1446            slug: Some("full-post".to_string()),
1447            visibility: Some("members".to_string()),
1448            featured: Some(true),
1449            email_only: Some(false),
1450            published_at: Some("2026-01-01T00:00:00.000Z".to_string()),
1451            lexical: Some("{\"root\":{}}".to_string()),
1452            html: Some("<p>Hello</p>".to_string()),
1453            custom_excerpt: Some("Summary".to_string()),
1454            feature_image: Some("https://example.com/img.jpg".to_string()),
1455            feature_image_alt: Some("Alt text".to_string()),
1456            feature_image_caption: Some("Caption".to_string()),
1457            tags: Some(vec![TagRef::by_slug("rust"), TagRef::by_name("New Tag")]),
1458            authors: Some(vec![AuthorRef::by_email("jane@example.com")]),
1459            newsletter: Some("weekly".to_string()),
1460            meta_title: Some("SEO Title".to_string()),
1461            meta_description: Some("SEO Desc".to_string()),
1462            canonical_url: Some("https://example.com/canonical".to_string()),
1463            og_image: Some("https://example.com/og.jpg".to_string()),
1464            og_title: Some("OG Title".to_string()),
1465            og_description: Some("OG Desc".to_string()),
1466            twitter_image: Some("https://example.com/tw.jpg".to_string()),
1467            twitter_title: Some("TW Title".to_string()),
1468            twitter_description: Some("TW Desc".to_string()),
1469            codeinjection_head: Some("<meta name='test' content='1'>".to_string()),
1470            codeinjection_foot: Some("<script>console.log('ok')</script>".to_string()),
1471            custom_template: Some("custom".to_string()),
1472        };
1473
1474        let json = serde_json::to_value(&p).unwrap();
1475        assert_eq!(json["title"], "Full Post");
1476        assert_eq!(json["status"], "published");
1477        assert_eq!(json["slug"], "full-post");
1478        assert_eq!(json["visibility"], "members");
1479        assert_eq!(json["featured"], true);
1480        assert_eq!(json["email_only"], false);
1481        assert_eq!(json["published_at"], "2026-01-01T00:00:00.000Z");
1482        assert_eq!(json["custom_excerpt"], "Summary");
1483        assert_eq!(json["feature_image"], "https://example.com/img.jpg");
1484        assert_eq!(json["feature_image_alt"], "Alt text");
1485        assert_eq!(json["feature_image_caption"], "Caption");
1486        assert_eq!(json["tags"][0]["slug"], "rust");
1487        assert_eq!(json["tags"][1]["name"], "New Tag");
1488        assert_eq!(json["authors"][0]["email"], "jane@example.com");
1489        assert_eq!(json["newsletter"], "weekly");
1490        assert_eq!(json["meta_title"], "SEO Title");
1491        assert_eq!(json["og_title"], "OG Title");
1492        assert_eq!(json["twitter_title"], "TW Title");
1493        assert_eq!(json["codeinjection_head"], "<meta name='test' content='1'>");
1494        assert_eq!(json["custom_template"], "custom");
1495    }
1496
1497    #[test]
1498    fn test_post_create_deserialization() {
1499        let json = json!({
1500            "title": "Deserialized Post",
1501            "status": "draft",
1502            "slug": "deserialized-post",
1503            "tags": [{"slug": "rust"}]
1504        });
1505        let p: PostCreate = serde_json::from_value(json).unwrap();
1506        assert_eq!(p.title, "Deserialized Post");
1507        assert_eq!(p.status, Some(PostStatus::Draft));
1508        assert_eq!(p.slug.as_deref(), Some("deserialized-post"));
1509        assert_eq!(p.tags.as_ref().unwrap().len(), 1);
1510    }
1511
1512    #[test]
1513    fn test_post_create_scheduled_status() {
1514        let p = PostCreate {
1515            title: "Future Post".to_string(),
1516            status: Some(PostStatus::Scheduled),
1517            published_at: Some("2027-06-01T09:00:00.000Z".to_string()),
1518            ..Default::default()
1519        };
1520        let json = serde_json::to_value(&p).unwrap();
1521        assert_eq!(json["status"], "scheduled");
1522        assert_eq!(json["published_at"], "2027-06-01T09:00:00.000Z");
1523    }
1524
1525    #[test]
1526    fn test_post_create_clone_eq() {
1527        let p = PostCreate::new("Clone Me");
1528        assert_eq!(p.clone(), p);
1529    }
1530
1531    // ── PostCreateEnvelope ────────────────────────────────────────────────────
1532
1533    #[test]
1534    fn test_post_create_envelope_new() {
1535        let env = PostCreateEnvelope::new(PostCreate::new("Wrapped"));
1536        assert_eq!(env.posts.len(), 1);
1537        assert_eq!(env.posts[0].title, "Wrapped");
1538    }
1539
1540    #[test]
1541    fn test_post_create_envelope_serialization() {
1542        let env = PostCreateEnvelope::new(PostCreate::new("API Post"));
1543        let json = serde_json::to_value(&env).unwrap();
1544        assert!(json["posts"].is_array());
1545        assert_eq!(json["posts"].as_array().unwrap().len(), 1);
1546        assert_eq!(json["posts"][0]["title"], "API Post");
1547    }
1548
1549    #[test]
1550    fn test_post_create_envelope_omits_none_fields() {
1551        let env = PostCreateEnvelope::new(PostCreate::new("Minimal"));
1552        let json = serde_json::to_value(&env).unwrap();
1553        let post_obj = json["posts"][0].as_object().unwrap();
1554        assert_eq!(post_obj.len(), 1, "only 'title' key should be present");
1555        assert!(post_obj.contains_key("title"));
1556    }
1557
1558    #[test]
1559    fn test_post_create_envelope_deserialization() {
1560        let json = json!({
1561            "posts": [{"title": "From API", "status": "published"}]
1562        });
1563        let env: PostCreateEnvelope = serde_json::from_value(json).unwrap();
1564        assert_eq!(env.posts.len(), 1);
1565        assert_eq!(env.posts[0].title, "From API");
1566        assert_eq!(env.posts[0].status, Some(PostStatus::Published));
1567    }
1568
1569    #[test]
1570    fn test_post_create_envelope_clone() {
1571        let env = PostCreateEnvelope::new(PostCreate::new("Clone"));
1572        let cloned = env.clone();
1573        assert_eq!(cloned.posts[0].title, "Clone");
1574    }
1575
1576    // ── PostUpdate ────────────────────────────────────────────────────────────
1577
1578    const UPDATED_AT: &str = "2026-01-15T10:30:00.000Z";
1579
1580    #[test]
1581    fn test_post_update_new_required_only() {
1582        let u = PostUpdate::new(UPDATED_AT);
1583        assert_eq!(u.updated_at, UPDATED_AT);
1584        assert!(u.title.is_none());
1585        assert!(u.status.is_none());
1586        assert!(u.slug.is_none());
1587        assert!(u.lexical.is_none());
1588        assert!(u.html.is_none());
1589        assert!(u.custom_excerpt.is_none());
1590        assert!(u.visibility.is_none());
1591        assert!(u.featured.is_none());
1592        assert!(u.email_only.is_none());
1593        assert!(u.published_at.is_none());
1594        assert!(u.feature_image.is_none());
1595        assert!(u.feature_image_alt.is_none());
1596        assert!(u.feature_image_caption.is_none());
1597        assert!(u.tags.is_none());
1598        assert!(u.authors.is_none());
1599        assert!(u.newsletter.is_none());
1600        assert!(u.meta_title.is_none());
1601        assert!(u.meta_description.is_none());
1602        assert!(u.canonical_url.is_none());
1603        assert!(u.og_image.is_none());
1604        assert!(u.og_title.is_none());
1605        assert!(u.og_description.is_none());
1606        assert!(u.twitter_image.is_none());
1607        assert!(u.twitter_title.is_none());
1608        assert!(u.twitter_description.is_none());
1609        assert!(u.codeinjection_head.is_none());
1610        assert!(u.codeinjection_foot.is_none());
1611        assert!(u.custom_template.is_none());
1612    }
1613
1614    #[test]
1615    fn test_post_update_default_updated_at_empty() {
1616        let u = PostUpdate::default();
1617        assert_eq!(u.updated_at, "");
1618    }
1619
1620    #[test]
1621    fn test_post_update_serializes_updated_at_always() {
1622        // updated_at must ALWAYS be present — it has no skip_serializing_if
1623        let u = PostUpdate::new(UPDATED_AT);
1624        let json = serde_json::to_value(&u).unwrap();
1625        assert_eq!(json["updated_at"], UPDATED_AT);
1626    }
1627
1628    #[test]
1629    fn test_post_update_minimal_has_only_updated_at() {
1630        let u = PostUpdate::new(UPDATED_AT);
1631        let json = serde_json::to_value(&u).unwrap();
1632        let obj = json.as_object().unwrap();
1633        assert_eq!(obj.len(), 1, "only 'updated_at' should be serialised");
1634        assert!(obj.contains_key("updated_at"));
1635    }
1636
1637    #[test]
1638    fn test_post_update_with_title() {
1639        let u = PostUpdate {
1640            updated_at: UPDATED_AT.to_string(),
1641            title: Some("New Title".to_string()),
1642            ..Default::default()
1643        };
1644        let json = serde_json::to_value(&u).unwrap();
1645        assert_eq!(json["updated_at"], UPDATED_AT);
1646        assert_eq!(json["title"], "New Title");
1647        // No other keys should be present
1648        assert_eq!(json.as_object().unwrap().len(), 2);
1649    }
1650
1651    #[test]
1652    fn test_post_update_publish() {
1653        let u = PostUpdate {
1654            updated_at: UPDATED_AT.to_string(),
1655            status: Some(PostStatus::Published),
1656            ..Default::default()
1657        };
1658        let json = serde_json::to_value(&u).unwrap();
1659        assert_eq!(json["status"], "published");
1660    }
1661
1662    #[test]
1663    fn test_post_update_unpublish_to_draft() {
1664        let u = PostUpdate {
1665            updated_at: UPDATED_AT.to_string(),
1666            status: Some(PostStatus::Draft),
1667            ..Default::default()
1668        };
1669        let json = serde_json::to_value(&u).unwrap();
1670        assert_eq!(json["status"], "draft");
1671    }
1672
1673    #[test]
1674    fn test_post_update_schedule() {
1675        let u = PostUpdate {
1676            updated_at: UPDATED_AT.to_string(),
1677            status: Some(PostStatus::Scheduled),
1678            published_at: Some("2027-03-01T09:00:00.000Z".to_string()),
1679            ..Default::default()
1680        };
1681        let json = serde_json::to_value(&u).unwrap();
1682        assert_eq!(json["status"], "scheduled");
1683        assert_eq!(json["published_at"], "2027-03-01T09:00:00.000Z");
1684    }
1685
1686    #[test]
1687    fn test_post_update_with_all_fields() {
1688        let u = PostUpdate {
1689            updated_at: UPDATED_AT.to_string(),
1690            title: Some("Updated Title".to_string()),
1691            lexical: Some("{\"root\":{}}".to_string()),
1692            html: Some("<p>Updated</p>".to_string()),
1693            custom_excerpt: Some("Updated excerpt".to_string()),
1694            slug: Some("updated-slug".to_string()),
1695            status: Some(PostStatus::Published),
1696            visibility: Some("members".to_string()),
1697            email_only: Some(false),
1698            featured: Some(true),
1699            published_at: Some("2026-06-01T09:00:00.000Z".to_string()),
1700            feature_image: Some("https://example.com/new.jpg".to_string()),
1701            feature_image_alt: Some("New alt".to_string()),
1702            feature_image_caption: Some("New caption".to_string()),
1703            tags: Some(vec![TagRef::by_slug("rust")]),
1704            authors: Some(vec![AuthorRef::by_email("bob@example.com")]),
1705            newsletter: Some("weekly".to_string()),
1706            meta_title: Some("New SEO Title".to_string()),
1707            meta_description: Some("New SEO Desc".to_string()),
1708            canonical_url: Some("https://example.com/new-canon".to_string()),
1709            og_image: Some("https://example.com/og-new.jpg".to_string()),
1710            og_title: Some("New OG Title".to_string()),
1711            og_description: Some("New OG Desc".to_string()),
1712            twitter_image: Some("https://example.com/tw-new.jpg".to_string()),
1713            twitter_title: Some("New TW Title".to_string()),
1714            twitter_description: Some("New TW Desc".to_string()),
1715            codeinjection_head: Some("<meta name='v' content='2'>".to_string()),
1716            codeinjection_foot: Some("<script>console.log('v2')</script>".to_string()),
1717            custom_template: Some("custom-v2".to_string()),
1718        };
1719
1720        let json = serde_json::to_value(&u).unwrap();
1721        assert_eq!(json["updated_at"], UPDATED_AT);
1722        assert_eq!(json["title"], "Updated Title");
1723        assert_eq!(json["status"], "published");
1724        assert_eq!(json["slug"], "updated-slug");
1725        assert_eq!(json["visibility"], "members");
1726        assert_eq!(json["featured"], true);
1727        assert_eq!(json["email_only"], false);
1728        assert_eq!(json["tags"][0]["slug"], "rust");
1729        assert_eq!(json["authors"][0]["email"], "bob@example.com");
1730        assert_eq!(json["newsletter"], "weekly");
1731        assert_eq!(json["meta_title"], "New SEO Title");
1732        assert_eq!(json["og_title"], "New OG Title");
1733        assert_eq!(json["twitter_title"], "New TW Title");
1734        assert_eq!(json["codeinjection_head"], "<meta name='v' content='2'>");
1735        assert_eq!(json["custom_template"], "custom-v2");
1736    }
1737
1738    #[test]
1739    fn test_post_update_deserialization() {
1740        let json = json!({
1741            "updated_at": UPDATED_AT,
1742            "title": "Round-tripped",
1743            "status": "draft"
1744        });
1745        let u: PostUpdate = serde_json::from_value(json).unwrap();
1746        assert_eq!(u.updated_at, UPDATED_AT);
1747        assert_eq!(u.title.as_deref(), Some("Round-tripped"));
1748        assert_eq!(u.status, Some(PostStatus::Draft));
1749        assert!(u.tags.is_none());
1750    }
1751
1752    #[test]
1753    fn test_post_update_deserialization_missing_optional_fields() {
1754        // Only updated_at present — all options should deserialise to None
1755        let json = json!({"updated_at": UPDATED_AT});
1756        let u: PostUpdate = serde_json::from_value(json).unwrap();
1757        assert_eq!(u.updated_at, UPDATED_AT);
1758        assert!(u.title.is_none());
1759        assert!(u.status.is_none());
1760    }
1761
1762    #[test]
1763    fn test_post_update_clone_eq() {
1764        let u = PostUpdate::new(UPDATED_AT);
1765        assert_eq!(u.clone(), u);
1766    }
1767
1768    #[test]
1769    fn test_post_update_differs_on_updated_at() {
1770        let u1 = PostUpdate::new("2026-01-01T00:00:00.000Z");
1771        let u2 = PostUpdate::new("2026-01-02T00:00:00.000Z");
1772        assert_ne!(u1, u2);
1773    }
1774
1775    // ── PostUpdateEnvelope ────────────────────────────────────────────────────
1776
1777    #[test]
1778    fn test_post_update_envelope_new() {
1779        let env = PostUpdateEnvelope::new(PostUpdate::new(UPDATED_AT));
1780        assert_eq!(env.posts.len(), 1);
1781        assert_eq!(env.posts[0].updated_at, UPDATED_AT);
1782    }
1783
1784    #[test]
1785    fn test_post_update_envelope_serialization() {
1786        let env = PostUpdateEnvelope::new(PostUpdate::new(UPDATED_AT));
1787        let json = serde_json::to_value(&env).unwrap();
1788        assert!(json["posts"].is_array());
1789        assert_eq!(json["posts"].as_array().unwrap().len(), 1);
1790        assert_eq!(json["posts"][0]["updated_at"], UPDATED_AT);
1791    }
1792
1793    #[test]
1794    fn test_post_update_envelope_minimal_has_only_updated_at_inside() {
1795        let env = PostUpdateEnvelope::new(PostUpdate::new(UPDATED_AT));
1796        let json = serde_json::to_value(&env).unwrap();
1797        let post_obj = json["posts"][0].as_object().unwrap();
1798        assert_eq!(
1799            post_obj.len(),
1800            1,
1801            "only 'updated_at' should be in the posts[0] object"
1802        );
1803    }
1804
1805    #[test]
1806    fn test_post_update_envelope_deserialization() {
1807        let json = json!({
1808            "posts": [{"updated_at": UPDATED_AT, "status": "published"}]
1809        });
1810        let env: PostUpdateEnvelope = serde_json::from_value(json).unwrap();
1811        assert_eq!(env.posts.len(), 1);
1812        assert_eq!(env.posts[0].updated_at, UPDATED_AT);
1813        assert_eq!(env.posts[0].status, Some(PostStatus::Published));
1814    }
1815
1816    #[test]
1817    fn test_post_update_envelope_clone() {
1818        let env = PostUpdateEnvelope::new(PostUpdate::new(UPDATED_AT));
1819        let cloned = env.clone();
1820        assert_eq!(cloned.posts[0].updated_at, UPDATED_AT);
1821    }
1822}