1use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum PostStatus {
49 #[default]
51 Draft,
52 Published,
54 Scheduled,
56 Sent,
58}
59
60impl PostStatus {
61 pub fn is_published(&self) -> bool {
72 matches!(self, PostStatus::Published)
73 }
74
75 pub fn is_draft(&self) -> bool {
86 matches!(self, PostStatus::Draft)
87 }
88
89 pub fn is_scheduled(&self) -> bool {
100 matches!(self, PostStatus::Scheduled)
101 }
102
103 pub fn is_sent(&self) -> bool {
114 matches!(self, PostStatus::Sent)
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
164pub struct Post {
165 #[serde(skip_serializing_if = "String::is_empty", default)]
168 pub id: String,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub uuid: Option<String>,
173
174 #[serde(skip_serializing_if = "String::is_empty", default)]
176 pub slug: String,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub comment_id: Option<String>,
181
182 pub title: String,
185
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub lexical: Option<String>,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub html: Option<String>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub excerpt: Option<String>,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub custom_excerpt: Option<String>,
201
202 #[serde(default)]
205 pub status: PostStatus,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub visibility: Option<String>,
210
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub email_only: Option<bool>,
214
215 #[serde(skip_serializing_if = "Option::is_none")]
218 pub created_at: Option<String>,
219
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub updated_at: Option<String>,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub published_at: Option<String>,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
231 pub feature_image: Option<String>,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub feature_image_alt: Option<String>,
236
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub feature_image_caption: Option<String>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
244 pub featured: Option<bool>,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
251 pub tags: Option<serde_json::Value>,
252
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub authors: Option<serde_json::Value>,
256
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub primary_author: Option<serde_json::Value>,
260
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub primary_tag: Option<serde_json::Value>,
264
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub newsletter: Option<serde_json::Value>,
268
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub email: Option<serde_json::Value>,
272
273 #[serde(skip_serializing_if = "Option::is_none")]
276 pub url: Option<String>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub canonical_url: Option<String>,
281
282 #[serde(skip_serializing_if = "Option::is_none")]
285 pub meta_title: Option<String>,
286
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub meta_description: Option<String>,
290
291 #[serde(skip_serializing_if = "Option::is_none")]
294 pub og_image: Option<String>,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub og_title: Option<String>,
299
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub og_description: Option<String>,
303
304 #[serde(skip_serializing_if = "Option::is_none")]
307 pub twitter_image: Option<String>,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub twitter_title: Option<String>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub twitter_description: Option<String>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
320 pub codeinjection_head: Option<String>,
321
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub codeinjection_foot: Option<String>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub custom_template: Option<String>,
329}
330
331impl Post {
332 pub fn is_published(&self) -> bool {
344 self.status.is_published()
345 }
346
347 pub fn is_draft(&self) -> bool {
359 self.status.is_draft()
360 }
361
362 pub fn is_scheduled(&self) -> bool {
374 self.status.is_scheduled()
375 }
376
377 pub fn is_sent(&self) -> bool {
389 self.status.is_sent()
390 }
391
392 pub fn is_featured(&self) -> bool {
404 self.featured.unwrap_or(false)
405 }
406
407 pub fn is_email_only(&self) -> bool {
419 self.email_only.unwrap_or(false)
420 }
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
442pub struct TagRef {
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub name: Option<String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub slug: Option<String>,
449}
450
451impl TagRef {
452 pub fn by_slug(slug: impl Into<String>) -> Self {
463 Self {
464 slug: Some(slug.into()),
465 name: None,
466 }
467 }
468
469 pub fn by_name(name: impl Into<String>) -> Self {
480 Self {
481 name: Some(name.into()),
482 slug: None,
483 }
484 }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
497pub struct AuthorRef {
498 #[serde(skip_serializing_if = "Option::is_none")]
500 pub id: Option<String>,
501 #[serde(skip_serializing_if = "Option::is_none")]
503 pub email: Option<String>,
504}
505
506impl AuthorRef {
507 pub fn by_id(id: impl Into<String>) -> Self {
518 Self {
519 id: Some(id.into()),
520 email: None,
521 }
522 }
523
524 pub fn by_email(email: impl Into<String>) -> Self {
535 Self {
536 email: Some(email.into()),
537 id: None,
538 }
539 }
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
567pub struct PostCreate {
568 pub title: String,
571
572 #[serde(skip_serializing_if = "Option::is_none")]
575 pub lexical: Option<String>,
576
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub html: Option<String>,
580
581 #[serde(skip_serializing_if = "Option::is_none")]
583 pub custom_excerpt: Option<String>,
584
585 #[serde(skip_serializing_if = "Option::is_none")]
588 pub slug: Option<String>,
589
590 #[serde(skip_serializing_if = "Option::is_none")]
593 pub status: Option<PostStatus>,
594
595 #[serde(skip_serializing_if = "Option::is_none")]
597 pub visibility: Option<String>,
598
599 #[serde(skip_serializing_if = "Option::is_none")]
601 pub email_only: Option<bool>,
602
603 #[serde(skip_serializing_if = "Option::is_none")]
605 pub featured: Option<bool>,
606
607 #[serde(skip_serializing_if = "Option::is_none")]
610 pub published_at: Option<String>,
611
612 #[serde(skip_serializing_if = "Option::is_none")]
615 pub feature_image: Option<String>,
616
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub feature_image_alt: Option<String>,
620
621 #[serde(skip_serializing_if = "Option::is_none")]
623 pub feature_image_caption: Option<String>,
624
625 #[serde(skip_serializing_if = "Option::is_none")]
628 pub tags: Option<Vec<TagRef>>,
629
630 #[serde(skip_serializing_if = "Option::is_none")]
632 pub authors: Option<Vec<AuthorRef>>,
633
634 #[serde(skip_serializing_if = "Option::is_none")]
636 pub newsletter: Option<String>,
637
638 #[serde(skip_serializing_if = "Option::is_none")]
641 pub meta_title: Option<String>,
642
643 #[serde(skip_serializing_if = "Option::is_none")]
645 pub meta_description: Option<String>,
646
647 #[serde(skip_serializing_if = "Option::is_none")]
649 pub canonical_url: Option<String>,
650
651 #[serde(skip_serializing_if = "Option::is_none")]
654 pub og_image: Option<String>,
655
656 #[serde(skip_serializing_if = "Option::is_none")]
658 pub og_title: Option<String>,
659
660 #[serde(skip_serializing_if = "Option::is_none")]
662 pub og_description: Option<String>,
663
664 #[serde(skip_serializing_if = "Option::is_none")]
667 pub twitter_image: Option<String>,
668
669 #[serde(skip_serializing_if = "Option::is_none")]
671 pub twitter_title: Option<String>,
672
673 #[serde(skip_serializing_if = "Option::is_none")]
675 pub twitter_description: Option<String>,
676
677 #[serde(skip_serializing_if = "Option::is_none")]
680 pub codeinjection_head: Option<String>,
681
682 #[serde(skip_serializing_if = "Option::is_none")]
684 pub codeinjection_foot: Option<String>,
685
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub custom_template: Option<String>,
689}
690
691impl PostCreate {
692 pub fn new(title: impl Into<String>) -> Self {
708 Self {
709 title: title.into(),
710 ..Default::default()
711 }
712 }
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct PostCreateEnvelope {
733 pub posts: Vec<PostCreate>,
735}
736
737impl PostCreateEnvelope {
738 pub fn new(post: PostCreate) -> Self {
750 Self { posts: vec![post] }
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
783pub struct PostUpdate {
784 pub updated_at: String,
790
791 #[serde(skip_serializing_if = "Option::is_none")]
794 pub title: Option<String>,
795
796 #[serde(skip_serializing_if = "Option::is_none")]
798 pub lexical: Option<String>,
799
800 #[serde(skip_serializing_if = "Option::is_none")]
802 pub html: Option<String>,
803
804 #[serde(skip_serializing_if = "Option::is_none")]
806 pub custom_excerpt: Option<String>,
807
808 #[serde(skip_serializing_if = "Option::is_none")]
811 pub slug: Option<String>,
812
813 #[serde(skip_serializing_if = "Option::is_none")]
816 pub status: Option<PostStatus>,
817
818 #[serde(skip_serializing_if = "Option::is_none")]
820 pub visibility: Option<String>,
821
822 #[serde(skip_serializing_if = "Option::is_none")]
824 pub email_only: Option<bool>,
825
826 #[serde(skip_serializing_if = "Option::is_none")]
828 pub featured: Option<bool>,
829
830 #[serde(skip_serializing_if = "Option::is_none")]
834 pub published_at: Option<String>,
835
836 #[serde(skip_serializing_if = "Option::is_none")]
839 pub feature_image: Option<String>,
840
841 #[serde(skip_serializing_if = "Option::is_none")]
843 pub feature_image_alt: Option<String>,
844
845 #[serde(skip_serializing_if = "Option::is_none")]
847 pub feature_image_caption: Option<String>,
848
849 #[serde(skip_serializing_if = "Option::is_none")]
852 pub tags: Option<Vec<TagRef>>,
853
854 #[serde(skip_serializing_if = "Option::is_none")]
856 pub authors: Option<Vec<AuthorRef>>,
857
858 #[serde(skip_serializing_if = "Option::is_none")]
860 pub newsletter: Option<String>,
861
862 #[serde(skip_serializing_if = "Option::is_none")]
865 pub meta_title: Option<String>,
866
867 #[serde(skip_serializing_if = "Option::is_none")]
869 pub meta_description: Option<String>,
870
871 #[serde(skip_serializing_if = "Option::is_none")]
873 pub canonical_url: Option<String>,
874
875 #[serde(skip_serializing_if = "Option::is_none")]
878 pub og_image: Option<String>,
879
880 #[serde(skip_serializing_if = "Option::is_none")]
882 pub og_title: Option<String>,
883
884 #[serde(skip_serializing_if = "Option::is_none")]
886 pub og_description: Option<String>,
887
888 #[serde(skip_serializing_if = "Option::is_none")]
891 pub twitter_image: Option<String>,
892
893 #[serde(skip_serializing_if = "Option::is_none")]
895 pub twitter_title: Option<String>,
896
897 #[serde(skip_serializing_if = "Option::is_none")]
899 pub twitter_description: Option<String>,
900
901 #[serde(skip_serializing_if = "Option::is_none")]
904 pub codeinjection_head: Option<String>,
905
906 #[serde(skip_serializing_if = "Option::is_none")]
908 pub codeinjection_foot: Option<String>,
909
910 #[serde(skip_serializing_if = "Option::is_none")]
912 pub custom_template: Option<String>,
913}
914
915impl PostUpdate {
916 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#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct PostUpdateEnvelope {
957 pub posts: Vec<PostUpdate>,
959}
960
961impl PostUpdateEnvelope {
962 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 #[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 #[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 #[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 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 #[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 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 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 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 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 #[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}