Skip to main content

mkt_core/models/
post.rs

1//! Post domain model.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// Opaque post identifier.
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct PostId(pub String);
10
11impl fmt::Display for PostId {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        write!(f, "{}", self.0)
14    }
15}
16
17impl<T: Into<String>> From<T> for PostId {
18    fn from(s: T) -> Self {
19        Self(s.into())
20    }
21}
22
23/// A unified post representation across all providers.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Post {
26    /// Unique identifier.
27    pub id: PostId,
28    /// Which provider this post belongs to.
29    pub provider: String,
30    /// The social platform (e.g. "facebook", "instagram").
31    pub platform: String,
32    /// Post message or caption.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub message: Option<String>,
35    /// Destination link URL.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub link: Option<String>,
38    /// Image URL attached to the post.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub image_url: Option<String>,
41    /// Public permalink to the post.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub permalink: Option<String>,
44    /// When the post was created.
45    pub created_at: DateTime<Utc>,
46    /// Original API response for debugging and raw access.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub raw: Option<serde_json::Value>,
49}
50
51/// Input for publishing a new post.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct PublishPostInput {
54    /// Target social platform.
55    pub platform: String,
56    /// Post message or caption.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub message: Option<String>,
59    /// Destination link URL.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub link: Option<String>,
62    /// Image URL to attach to the post.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub image_url: Option<String>,
65    /// Video URL to attach to the post.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub video_url: Option<String>,
68    /// Provider-specific extra fields.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub extra: Option<serde_json::Value>,
71}
72
73/// Input for promoting an existing post as a paid ad.
74///
75/// Promotion places the post as an ad inside an existing ad set;
76/// budget, schedule, and targeting are controlled by that ad set.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PromotePostInput {
79    /// The ad set the promoted-post ad is created in.
80    pub adset_id: String,
81    /// Name for the created ad (provider generates one if omitted).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    /// Provider-specific extra fields.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub extra: Option<serde_json::Value>,
87}
88
89impl crate::output::Formattable for Post {
90    fn headers() -> Vec<String> {
91        vec![
92            "ID".into(),
93            "Platform".into(),
94            "Message".into(),
95            "Permalink".into(),
96            "Provider".into(),
97        ]
98    }
99
100    fn row(&self) -> Vec<String> {
101        vec![
102            self.id.to_string(),
103            self.platform.clone(),
104            self.message
105                .as_deref()
106                .unwrap_or("-")
107                .chars()
108                .take(60)
109                .collect(),
110            self.permalink.clone().unwrap_or_else(|| "-".to_string()),
111            self.provider.clone(),
112        ]
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn post_id_display() {
122        let id = PostId("post_123".into());
123        assert_eq!(id.to_string(), "post_123");
124    }
125
126    #[test]
127    fn post_id_from_str() {
128        let id = PostId::from("post_456");
129        assert_eq!(id.0, "post_456");
130    }
131
132    #[test]
133    #[allow(clippy::expect_used)]
134    fn post_id_serde_roundtrip() {
135        let id = PostId("post_789".into());
136        let json = serde_json::to_string(&id).expect("serialize PostId");
137        let back: PostId = serde_json::from_str(&json).expect("deserialize PostId");
138        assert_eq!(id, back);
139    }
140
141    #[test]
142    fn publish_post_input_default() {
143        let input = PublishPostInput::default();
144        assert!(input.platform.is_empty());
145        assert!(input.message.is_none());
146        assert!(input.link.is_none());
147        assert!(input.image_url.is_none());
148        assert!(input.video_url.is_none());
149        assert!(input.extra.is_none());
150    }
151
152    #[test]
153    #[allow(clippy::expect_used)]
154    fn publish_post_input_skips_none_fields() {
155        let input = PublishPostInput {
156            platform: "facebook".into(),
157            message: Some("Hello!".into()),
158            link: None,
159            image_url: None,
160            video_url: None,
161            extra: None,
162        };
163        let json = serde_json::to_string(&input).expect("serialize PublishPostInput");
164        assert!(!json.contains("link"));
165        assert!(!json.contains("image_url"));
166        assert!(!json.contains("video_url"));
167        assert!(!json.contains("extra"));
168    }
169
170    #[test]
171    #[allow(clippy::expect_used)]
172    fn promote_post_input_serde_roundtrip() {
173        let input = PromotePostInput {
174            adset_id: "23845600000000001".into(),
175            name: Some("Boost — Summer Post".into()),
176            extra: None,
177        };
178        let json = serde_json::to_string(&input).expect("serialize PromotePostInput");
179        let back: PromotePostInput =
180            serde_json::from_str(&json).expect("deserialize PromotePostInput");
181        assert_eq!(back.adset_id, "23845600000000001");
182        assert_eq!(back.name.as_deref(), Some("Boost — Summer Post"));
183        assert!(back.extra.is_none());
184    }
185
186    #[test]
187    #[allow(clippy::expect_used)]
188    fn promote_post_input_optional_fields_skip_serializing_if_none() {
189        let input = PromotePostInput {
190            adset_id: "1".into(),
191            name: None,
192            extra: None,
193        };
194        let json = serde_json::to_string(&input).expect("serialize PromotePostInput");
195        assert!(!json.contains("name"));
196        assert!(!json.contains("extra"));
197    }
198
199    #[test]
200    #[allow(clippy::expect_used)]
201    fn post_optional_fields_skip_serializing_if_none() {
202        let now = chrono::Utc::now();
203        let post = Post {
204            id: PostId("p_1".into()),
205            provider: "meta".into(),
206            platform: "facebook".into(),
207            message: None,
208            link: None,
209            image_url: None,
210            permalink: None,
211            created_at: now,
212            raw: None,
213        };
214        let json = serde_json::to_string(&post).expect("serialize Post");
215        assert!(!json.contains("message"));
216        assert!(!json.contains("link"));
217        assert!(!json.contains("image_url"));
218        assert!(!json.contains("permalink"));
219        assert!(!json.contains("raw"));
220    }
221}