Skip to main content

shopify_sdk/rest/resources/v2025_10/
article.rs

1//! Article resource implementation.
2//!
3//! This module provides the Article resource, which represents a blog article
4//! in a Shopify store. Articles are nested under blogs and follow the same
5//! nested path pattern as Variants under Products.
6//!
7//! # Nested Path Pattern
8//!
9//! Articles are always accessed under a blog:
10//! - List: `/blogs/{blog_id}/articles`
11//! - Find: `/blogs/{blog_id}/articles/{id}`
12//! - Create: `/blogs/{blog_id}/articles`
13//! - Update: `/blogs/{blog_id}/articles/{id}`
14//! - Delete: `/blogs/{blog_id}/articles/{id}`
15//! - Count: `/blogs/{blog_id}/articles/count`
16//!
17//! Use `Article::all_with_parent()` to list articles under a specific blog.
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use shopify_sdk::rest::{RestResource, ResourceResponse};
23//! use shopify_sdk::rest::resources::v2025_10::{Article, ArticleListParams};
24//!
25//! // List articles under a specific blog
26//! let articles = Article::all_with_parent(&client, "blog_id", 123, None).await?;
27//! for article in articles.iter() {
28//!     println!("Article: {}", article.title.as_deref().unwrap_or(""));
29//! }
30//!
31//! // Create a new article under a blog
32//! let mut article = Article {
33//!     blog_id: Some(123),
34//!     title: Some("New Post".to_string()),
35//!     body_html: Some("<p>Article content</p>".to_string()),
36//!     author: Some("Admin".to_string()),
37//!     ..Default::default()
38//! };
39//! let saved = article.save(&client).await?;
40//!
41//! // Count articles in a blog
42//! let count = Article::count_with_parent(&client, "blog_id", 123, None).await?;
43//! println!("Total articles: {}", count);
44//! ```
45
46use std::collections::HashMap;
47
48use chrono::{DateTime, Utc};
49use serde::{Deserialize, Serialize};
50
51use crate::clients::RestClient;
52use crate::rest::{
53    build_path, get_path, ResourceError, ResourceOperation, ResourcePath, RestResource,
54};
55use crate::HttpMethod;
56
57/// An image associated with an article.
58///
59/// Similar to `CollectionImage`, this represents the featured image
60/// for a blog article.
61#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
62pub struct ArticleImage {
63    /// The source URL of the article image.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub src: Option<String>,
66
67    /// Alternative text for the image (for accessibility).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub alt: Option<String>,
70
71    /// The width of the image in pixels.
72    #[serde(skip_serializing)]
73    pub width: Option<i64>,
74
75    /// The height of the image in pixels.
76    #[serde(skip_serializing)]
77    pub height: Option<i64>,
78
79    /// When the image was created.
80    /// Read-only field.
81    #[serde(skip_serializing)]
82    pub created_at: Option<DateTime<Utc>>,
83}
84
85/// A blog article in a Shopify store.
86///
87/// Articles are blog posts that belong to a specific blog. They are
88/// nested resources that require a `blog_id` for all operations.
89///
90/// # Nested Resource
91///
92/// Articles follow the same nested path pattern as Variants:
93/// - All operations require `blog_id` context
94/// - Use `all_with_parent()` to list articles under a blog
95/// - The `blog_id` field is required for creating new articles
96///
97/// # Fields
98///
99/// ## Read-Only Fields
100/// - `id` - The unique identifier of the article
101/// - `blog_id` - The ID of the blog this article belongs to (also required for creation)
102/// - `user_id` - The ID of the user who authored the article
103/// - `created_at` - When the article was created
104/// - `updated_at` - When the article was last updated
105/// - `admin_graphql_api_id` - The GraphQL API ID
106///
107/// ## Writable Fields
108/// - `title` - The title of the article
109/// - `handle` - The URL-friendly handle (auto-generated from title if not set)
110/// - `body_html` - The HTML content of the article
111/// - `author` - The author name displayed on the article
112/// - `summary_html` - The summary/excerpt of the article
113/// - `template_suffix` - The suffix of the Liquid template used for the article
114/// - `tags` - Comma-separated tags for the article
115/// - `image` - The featured image for the article
116/// - `published_at` - When the article was published (can be set to future for scheduling)
117#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
118pub struct Article {
119    /// The unique identifier of the article.
120    /// Read-only field.
121    #[serde(skip_serializing)]
122    pub id: Option<u64>,
123
124    /// The ID of the blog this article belongs to.
125    /// Required for creating new articles.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub blog_id: Option<u64>,
128
129    /// The title of the article.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub title: Option<String>,
132
133    /// The URL-friendly handle of the article.
134    /// Auto-generated from the title if not specified.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub handle: Option<String>,
137
138    /// The HTML content of the article.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub body_html: Option<String>,
141
142    /// The author name displayed on the article.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub author: Option<String>,
145
146    /// The summary/excerpt of the article in HTML.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub summary_html: Option<String>,
149
150    /// The suffix of the Liquid template used for the article.
151    /// For example, if the value is "custom", the article uses the
152    /// `article.custom.liquid` template.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub template_suffix: Option<String>,
155
156    /// Comma-separated tags for the article.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub tags: Option<String>,
159
160    /// The featured image for the article.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub image: Option<ArticleImage>,
163
164    /// When the article was or will be published.
165    /// Set to a future date to schedule publication.
166    /// Set to `null` to unpublish.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub published_at: Option<DateTime<Utc>>,
169
170    /// The ID of the user who authored the article.
171    /// Read-only field.
172    #[serde(skip_serializing)]
173    pub user_id: Option<u64>,
174
175    /// When the article was created.
176    /// Read-only field.
177    #[serde(skip_serializing)]
178    pub created_at: Option<DateTime<Utc>>,
179
180    /// When the article was last updated.
181    /// Read-only field.
182    #[serde(skip_serializing)]
183    pub updated_at: Option<DateTime<Utc>>,
184
185    /// The admin GraphQL API ID for this article.
186    /// Read-only field.
187    #[serde(skip_serializing)]
188    pub admin_graphql_api_id: Option<String>,
189}
190
191impl Article {
192    /// Counts articles under a specific blog.
193    ///
194    /// # Arguments
195    ///
196    /// * `client` - The REST client to use for the request
197    /// * `parent_id_name` - The name of the parent ID parameter (should be `blog_id`)
198    /// * `parent_id` - The blog ID
199    /// * `params` - Optional parameters for filtering
200    ///
201    /// # Returns
202    ///
203    /// The count of matching articles as a `u64`.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`ResourceError::PathResolutionFailed`] if no count path exists.
208    ///
209    /// # Example
210    ///
211    /// ```rust,ignore
212    /// let count = Article::count_with_parent(&client, "blog_id", 123, None).await?;
213    /// println!("Articles in blog: {}", count);
214    /// ```
215    pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
216        client: &RestClient,
217        parent_id_name: &str,
218        parent_id: ParentId,
219        params: Option<ArticleCountParams>,
220    ) -> Result<u64, ResourceError> {
221        let mut ids: HashMap<&str, String> = HashMap::new();
222        ids.insert(parent_id_name, parent_id.to_string());
223
224        let available_ids: Vec<&str> = ids.keys().copied().collect();
225        let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
226            ResourceError::PathResolutionFailed {
227                resource: Self::NAME,
228                operation: "count",
229            },
230        )?;
231
232        let url = build_path(path.template, &ids);
233
234        // Build query params
235        let query = params
236            .map(|p| {
237                let value = serde_json::to_value(&p).map_err(|e| {
238                    ResourceError::Http(crate::clients::HttpError::Response(
239                        crate::clients::HttpResponseError {
240                            code: 400,
241                            message: format!("Failed to serialize params: {e}"),
242                            error_reference: None,
243                        },
244                    ))
245                })?;
246
247                let mut query = HashMap::new();
248                if let serde_json::Value::Object(map) = value {
249                    for (key, val) in map {
250                        match val {
251                            serde_json::Value::String(s) => {
252                                query.insert(key, s);
253                            }
254                            serde_json::Value::Number(n) => {
255                                query.insert(key, n.to_string());
256                            }
257                            serde_json::Value::Bool(b) => {
258                                query.insert(key, b.to_string());
259                            }
260                            // Skip null, arrays, and objects
261                            _ => {}
262                        }
263                    }
264                }
265                Ok::<_, ResourceError>(query)
266            })
267            .transpose()?
268            .filter(|q| !q.is_empty());
269
270        let response = client.get(&url, query).await?;
271
272        if !response.is_ok() {
273            return Err(ResourceError::from_http_response(
274                response.code,
275                &response.body,
276                Self::NAME,
277                None,
278                response.request_id(),
279            ));
280        }
281
282        // Extract count from response
283        let count = response
284            .body
285            .get("count")
286            .and_then(serde_json::Value::as_u64)
287            .ok_or_else(|| {
288                ResourceError::Http(crate::clients::HttpError::Response(
289                    crate::clients::HttpResponseError {
290                        code: response.code,
291                        message: "Missing 'count' in response".to_string(),
292                        error_reference: response.request_id().map(ToString::to_string),
293                    },
294                ))
295            })?;
296
297        Ok(count)
298    }
299}
300
301impl RestResource for Article {
302    type Id = u64;
303    type FindParams = ArticleFindParams;
304    type AllParams = ArticleListParams;
305    type CountParams = ArticleCountParams;
306
307    const NAME: &'static str = "Article";
308    const PLURAL: &'static str = "articles";
309
310    /// Paths for the Article resource.
311    ///
312    /// Articles are NESTED under blogs. All operations require `blog_id`.
313    /// This follows the same pattern as Variants nested under Products.
314    const PATHS: &'static [ResourcePath] = &[
315        // All paths require blog_id
316        ResourcePath::new(
317            HttpMethod::Get,
318            ResourceOperation::Find,
319            &["blog_id", "id"],
320            "blogs/{blog_id}/articles/{id}",
321        ),
322        ResourcePath::new(
323            HttpMethod::Get,
324            ResourceOperation::All,
325            &["blog_id"],
326            "blogs/{blog_id}/articles",
327        ),
328        ResourcePath::new(
329            HttpMethod::Get,
330            ResourceOperation::Count,
331            &["blog_id"],
332            "blogs/{blog_id}/articles/count",
333        ),
334        ResourcePath::new(
335            HttpMethod::Post,
336            ResourceOperation::Create,
337            &["blog_id"],
338            "blogs/{blog_id}/articles",
339        ),
340        ResourcePath::new(
341            HttpMethod::Put,
342            ResourceOperation::Update,
343            &["blog_id", "id"],
344            "blogs/{blog_id}/articles/{id}",
345        ),
346        ResourcePath::new(
347            HttpMethod::Delete,
348            ResourceOperation::Delete,
349            &["blog_id", "id"],
350            "blogs/{blog_id}/articles/{id}",
351        ),
352    ];
353
354    fn get_id(&self) -> Option<Self::Id> {
355        self.id
356    }
357}
358
359/// Parameters for finding a single article.
360#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
361pub struct ArticleFindParams {
362    /// Comma-separated list of fields to include in the response.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub fields: Option<String>,
365}
366
367/// Parameters for listing articles.
368#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
369pub struct ArticleListParams {
370    /// Filter by article author.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub author: Option<String>,
373
374    /// Filter by article handle.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub handle: Option<String>,
377
378    /// Filter by tag.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub tag: Option<String>,
381
382    /// Filter by published status.
383    /// Valid values: `published`, `unpublished`, `any`.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub published_status: Option<String>,
386
387    /// Show articles created after this date.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub created_at_min: Option<DateTime<Utc>>,
390
391    /// Show articles created before this date.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub created_at_max: Option<DateTime<Utc>>,
394
395    /// Show articles updated after this date.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub updated_at_min: Option<DateTime<Utc>>,
398
399    /// Show articles updated before this date.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub updated_at_max: Option<DateTime<Utc>>,
402
403    /// Show articles published after this date.
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub published_at_min: Option<DateTime<Utc>>,
406
407    /// Show articles published before this date.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub published_at_max: Option<DateTime<Utc>>,
410
411    /// Maximum number of results to return (default: 50, max: 250).
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub limit: Option<u32>,
414
415    /// Return articles after this ID.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub since_id: Option<u64>,
418
419    /// Cursor for pagination.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub page_info: Option<String>,
422
423    /// Comma-separated list of fields to include in the response.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub fields: Option<String>,
426}
427
428/// Parameters for counting articles.
429#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
430pub struct ArticleCountParams {
431    /// Filter by article author.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub author: Option<String>,
434
435    /// Filter by tag.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub tag: Option<String>,
438
439    /// Filter by published status.
440    /// Valid values: `published`, `unpublished`, `any`.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub published_status: Option<String>,
443
444    /// Show articles created after this date.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub created_at_min: Option<DateTime<Utc>>,
447
448    /// Show articles created before this date.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub created_at_max: Option<DateTime<Utc>>,
451
452    /// Show articles updated after this date.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub updated_at_min: Option<DateTime<Utc>>,
455
456    /// Show articles updated before this date.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub updated_at_max: Option<DateTime<Utc>>,
459
460    /// Show articles published after this date.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub published_at_min: Option<DateTime<Utc>>,
463
464    /// Show articles published before this date.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub published_at_max: Option<DateTime<Utc>>,
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::rest::{get_path, ResourceOperation};
473
474    #[test]
475    fn test_article_struct_serialization() {
476        let article = Article {
477            id: Some(12345),
478            blog_id: Some(67890),
479            title: Some("New Blog Post".to_string()),
480            handle: Some("new-blog-post".to_string()),
481            body_html: Some("<p>This is the article content.</p>".to_string()),
482            author: Some("Jane Doe".to_string()),
483            summary_html: Some("<p>Article summary.</p>".to_string()),
484            template_suffix: Some("custom".to_string()),
485            tags: Some("tech, rust, web".to_string()),
486            image: Some(ArticleImage {
487                src: Some("https://cdn.shopify.com/article.jpg".to_string()),
488                alt: Some("Article image".to_string()),
489                width: Some(1200),
490                height: Some(800),
491                created_at: None,
492            }),
493            published_at: Some(
494                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
495                    .unwrap()
496                    .with_timezone(&Utc),
497            ),
498            user_id: Some(111222),
499            created_at: Some(
500                DateTime::parse_from_rfc3339("2024-01-10T08:00:00Z")
501                    .unwrap()
502                    .with_timezone(&Utc),
503            ),
504            updated_at: Some(
505                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
506                    .unwrap()
507                    .with_timezone(&Utc),
508            ),
509            admin_graphql_api_id: Some("gid://shopify/OnlineStoreArticle/12345".to_string()),
510        };
511
512        let json = serde_json::to_string(&article).unwrap();
513        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
514
515        // Writable fields should be present
516        assert_eq!(parsed["blog_id"], 67890);
517        assert_eq!(parsed["title"], "New Blog Post");
518        assert_eq!(parsed["handle"], "new-blog-post");
519        assert_eq!(parsed["body_html"], "<p>This is the article content.</p>");
520        assert_eq!(parsed["author"], "Jane Doe");
521        assert_eq!(parsed["summary_html"], "<p>Article summary.</p>");
522        assert_eq!(parsed["template_suffix"], "custom");
523        assert_eq!(parsed["tags"], "tech, rust, web");
524        assert!(parsed["published_at"].as_str().is_some());
525        assert_eq!(
526            parsed["image"]["src"],
527            "https://cdn.shopify.com/article.jpg"
528        );
529        assert_eq!(parsed["image"]["alt"], "Article image");
530
531        // Read-only fields should be omitted
532        assert!(parsed.get("id").is_none());
533        assert!(parsed.get("user_id").is_none());
534        assert!(parsed.get("created_at").is_none());
535        assert!(parsed.get("updated_at").is_none());
536        assert!(parsed.get("admin_graphql_api_id").is_none());
537
538        // Image read-only fields should be omitted
539        assert!(parsed["image"].get("width").is_none());
540        assert!(parsed["image"].get("height").is_none());
541    }
542
543    #[test]
544    fn test_article_deserialization_from_api_response() {
545        let json = r#"{
546            "id": 134645308,
547            "blog_id": 241253187,
548            "title": "My new blog post",
549            "handle": "my-new-blog-post",
550            "body_html": "<p>This is the content of the article.</p>",
551            "author": "John Smith",
552            "summary_html": "<p>Summary here.</p>",
553            "template_suffix": null,
554            "tags": "tech, news",
555            "image": {
556                "src": "https://cdn.shopify.com/s/files/1/article.jpg",
557                "alt": "Blog image",
558                "width": 1200,
559                "height": 800,
560                "created_at": "2024-01-15T10:30:00Z"
561            },
562            "published_at": "2024-01-15T10:30:00Z",
563            "user_id": 799407056,
564            "created_at": "2024-01-10T08:00:00Z",
565            "updated_at": "2024-06-20T15:45:00Z",
566            "admin_graphql_api_id": "gid://shopify/OnlineStoreArticle/134645308"
567        }"#;
568
569        let article: Article = serde_json::from_str(json).unwrap();
570
571        assert_eq!(article.id, Some(134645308));
572        assert_eq!(article.blog_id, Some(241253187));
573        assert_eq!(article.title, Some("My new blog post".to_string()));
574        assert_eq!(article.handle, Some("my-new-blog-post".to_string()));
575        assert_eq!(
576            article.body_html,
577            Some("<p>This is the content of the article.</p>".to_string())
578        );
579        assert_eq!(article.author, Some("John Smith".to_string()));
580        assert_eq!(
581            article.summary_html,
582            Some("<p>Summary here.</p>".to_string())
583        );
584        assert!(article.template_suffix.is_none());
585        assert_eq!(article.tags, Some("tech, news".to_string()));
586        assert!(article.image.is_some());
587        let image = article.image.unwrap();
588        assert_eq!(
589            image.src,
590            Some("https://cdn.shopify.com/s/files/1/article.jpg".to_string())
591        );
592        assert_eq!(image.alt, Some("Blog image".to_string()));
593        assert_eq!(image.width, Some(1200));
594        assert_eq!(image.height, Some(800));
595        assert!(image.created_at.is_some());
596        assert!(article.published_at.is_some());
597        assert_eq!(article.user_id, Some(799407056));
598        assert!(article.created_at.is_some());
599        assert!(article.updated_at.is_some());
600        assert_eq!(
601            article.admin_graphql_api_id,
602            Some("gid://shopify/OnlineStoreArticle/134645308".to_string())
603        );
604    }
605
606    #[test]
607    fn test_article_nested_paths_require_blog_id() {
608        // All paths should require blog_id (nested under blogs)
609
610        // Find requires both blog_id and id
611        let find_path = get_path(Article::PATHS, ResourceOperation::Find, &["blog_id", "id"]);
612        assert!(find_path.is_some());
613        assert_eq!(find_path.unwrap().template, "blogs/{blog_id}/articles/{id}");
614
615        // Find with only id should fail (no standalone path)
616        let find_without_blog = get_path(Article::PATHS, ResourceOperation::Find, &["id"]);
617        assert!(find_without_blog.is_none());
618
619        // All requires blog_id
620        let all_path = get_path(Article::PATHS, ResourceOperation::All, &["blog_id"]);
621        assert!(all_path.is_some());
622        assert_eq!(all_path.unwrap().template, "blogs/{blog_id}/articles");
623
624        // All without blog_id should fail
625        let all_without_blog = get_path(Article::PATHS, ResourceOperation::All, &[]);
626        assert!(all_without_blog.is_none());
627
628        // Count requires blog_id
629        let count_path = get_path(Article::PATHS, ResourceOperation::Count, &["blog_id"]);
630        assert!(count_path.is_some());
631        assert_eq!(
632            count_path.unwrap().template,
633            "blogs/{blog_id}/articles/count"
634        );
635
636        // Create requires blog_id
637        let create_path = get_path(Article::PATHS, ResourceOperation::Create, &["blog_id"]);
638        assert!(create_path.is_some());
639        assert_eq!(create_path.unwrap().template, "blogs/{blog_id}/articles");
640
641        // Update requires both blog_id and id
642        let update_path = get_path(
643            Article::PATHS,
644            ResourceOperation::Update,
645            &["blog_id", "id"],
646        );
647        assert!(update_path.is_some());
648        assert_eq!(
649            update_path.unwrap().template,
650            "blogs/{blog_id}/articles/{id}"
651        );
652
653        // Delete requires both blog_id and id
654        let delete_path = get_path(
655            Article::PATHS,
656            ResourceOperation::Delete,
657            &["blog_id", "id"],
658        );
659        assert!(delete_path.is_some());
660        assert_eq!(
661            delete_path.unwrap().template,
662            "blogs/{blog_id}/articles/{id}"
663        );
664    }
665
666    #[test]
667    fn test_article_list_params_serialization() {
668        let params = ArticleListParams {
669            author: Some("Jane Doe".to_string()),
670            handle: Some("my-post".to_string()),
671            tag: Some("tech".to_string()),
672            published_status: Some("published".to_string()),
673            limit: Some(50),
674            since_id: Some(100),
675            ..Default::default()
676        };
677
678        let json = serde_json::to_value(&params).unwrap();
679
680        assert_eq!(json["author"], "Jane Doe");
681        assert_eq!(json["handle"], "my-post");
682        assert_eq!(json["tag"], "tech");
683        assert_eq!(json["published_status"], "published");
684        assert_eq!(json["limit"], 50);
685        assert_eq!(json["since_id"], 100);
686
687        // Fields not set should be omitted
688        assert!(json.get("created_at_min").is_none());
689        assert!(json.get("page_info").is_none());
690    }
691
692    #[test]
693    fn test_article_count_params_serialization() {
694        let params = ArticleCountParams {
695            author: Some("Jane Doe".to_string()),
696            tag: Some("tech".to_string()),
697            published_status: Some("any".to_string()),
698            ..Default::default()
699        };
700
701        let json = serde_json::to_value(&params).unwrap();
702
703        assert_eq!(json["author"], "Jane Doe");
704        assert_eq!(json["tag"], "tech");
705        assert_eq!(json["published_status"], "any");
706
707        // Test empty params
708        let empty_params = ArticleCountParams::default();
709        let empty_json = serde_json::to_value(&empty_params).unwrap();
710        assert_eq!(empty_json, serde_json::json!({}));
711    }
712
713    #[test]
714    fn test_article_tags_field_handling() {
715        // Tags as comma-separated string
716        let article = Article {
717            title: Some("Tech Article".to_string()),
718            tags: Some("rust, programming, web, api".to_string()),
719            ..Default::default()
720        };
721
722        let json = serde_json::to_value(&article).unwrap();
723        assert_eq!(json["tags"], "rust, programming, web, api");
724
725        // Deserialize tags back
726        let deserialized: Article = serde_json::from_value(json).unwrap();
727        assert_eq!(
728            deserialized.tags,
729            Some("rust, programming, web, api".to_string())
730        );
731    }
732
733    #[test]
734    fn test_article_get_id_returns_correct_value() {
735        // Article with ID
736        let article_with_id = Article {
737            id: Some(123456789),
738            blog_id: Some(987654321),
739            title: Some("Test Article".to_string()),
740            ..Default::default()
741        };
742        assert_eq!(article_with_id.get_id(), Some(123456789));
743
744        // Article without ID (new article)
745        let article_without_id = Article {
746            id: None,
747            blog_id: Some(987654321),
748            title: Some("New Article".to_string()),
749            ..Default::default()
750        };
751        assert_eq!(article_without_id.get_id(), None);
752    }
753
754    #[test]
755    fn test_article_image_struct() {
756        let image = ArticleImage {
757            src: Some("https://cdn.shopify.com/article-img.jpg".to_string()),
758            alt: Some("Featured image".to_string()),
759            width: Some(1920),
760            height: Some(1080),
761            created_at: Some(
762                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
763                    .unwrap()
764                    .with_timezone(&Utc),
765            ),
766        };
767
768        let json = serde_json::to_value(&image).unwrap();
769
770        // Writable fields should be present
771        assert_eq!(json["src"], "https://cdn.shopify.com/article-img.jpg");
772        assert_eq!(json["alt"], "Featured image");
773
774        // Read-only fields should be omitted
775        assert!(json.get("width").is_none());
776        assert!(json.get("height").is_none());
777        assert!(json.get("created_at").is_none());
778    }
779
780    #[test]
781    fn test_article_constants() {
782        assert_eq!(Article::NAME, "Article");
783        assert_eq!(Article::PLURAL, "articles");
784    }
785}