Skip to main content

shopify_sdk/rest/resources/v2025_10/
product.rs

1//! Product resource implementation.
2//!
3//! This module provides the Product resource, which represents a product in a Shopify store.
4//! Products can have variants, options, and images associated with them.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use shopify_sdk::rest::{RestResource, ResourceResponse};
10//! use shopify_sdk::rest::resources::v2025_10::{Product, ProductListParams, ProductStatus};
11//!
12//! // Find a single product
13//! let product = Product::find(&client, 123, None).await?;
14//! println!("Product: {}", product.title.as_deref().unwrap_or(""));
15//!
16//! // List products with filters
17//! let params = ProductListParams {
18//!     status: Some(ProductStatus::Active),
19//!     limit: Some(50),
20//!     ..Default::default()
21//! };
22//! let products = Product::all(&client, Some(params)).await?;
23//!
24//! // Create a new product
25//! let mut product = Product {
26//!     title: Some("My New Product".to_string()),
27//!     vendor: Some("My Store".to_string()),
28//!     product_type: Some("T-Shirts".to_string()),
29//!     ..Default::default()
30//! };
31//! let saved = product.save(&client).await?;
32//! ```
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37use crate::rest::{ResourceOperation, ResourcePath, RestResource};
38use crate::HttpMethod;
39
40use super::common::{ProductImage, ProductOption};
41
42/// The status of a product.
43///
44/// Determines whether a product is visible to customers.
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ProductStatus {
48    /// The product is active and visible to customers.
49    #[default]
50    Active,
51    /// The product is archived and not visible to customers.
52    Archived,
53    /// The product is a draft and not visible to customers.
54    Draft,
55}
56
57/// A variant embedded within a Product response.
58///
59/// This is a subset of the full Variant resource, containing only the fields
60/// that are included when variants are embedded in a Product response.
61///
62/// For full variant operations, use the `Variant` resource directly.
63#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
64pub struct ProductVariant {
65    /// The unique identifier of the variant.
66    #[serde(skip_serializing)]
67    pub id: Option<u64>,
68
69    /// The ID of the product this variant belongs to.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub product_id: Option<u64>,
72
73    /// The title of the variant.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub title: Option<String>,
76
77    /// The price of the variant.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub price: Option<String>,
80
81    /// The original price of the variant for comparison.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub compare_at_price: Option<String>,
84
85    /// The stock keeping unit (SKU) of the variant.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub sku: Option<String>,
88
89    /// The position of the variant in the product's variant list.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub position: Option<i64>,
92
93    /// The inventory quantity of the variant.
94    /// Read-only field.
95    #[serde(skip_serializing)]
96    pub inventory_quantity: Option<i64>,
97
98    /// The value of the first option.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub option1: Option<String>,
101
102    /// The value of the second option.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub option2: Option<String>,
105
106    /// The value of the third option.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub option3: Option<String>,
109
110    /// The ID of the image associated with this variant.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub image_id: Option<u64>,
113
114    /// When the variant was created.
115    /// Read-only field.
116    #[serde(skip_serializing)]
117    pub created_at: Option<DateTime<Utc>>,
118
119    /// When the variant was last updated.
120    /// Read-only field.
121    #[serde(skip_serializing)]
122    pub updated_at: Option<DateTime<Utc>>,
123}
124
125/// A product in a Shopify store.
126///
127/// Products are the goods or services that merchants sell. A product can have
128/// multiple variants (e.g., different sizes or colors), options, and images.
129///
130/// # Fields
131///
132/// ## Writable Fields
133/// - `title` - The name of the product
134/// - `body_html` - The description of the product in HTML format
135/// - `vendor` - The name of the product's vendor
136/// - `product_type` - A categorization for the product
137/// - `published_at` - When the product was published
138/// - `published_scope` - Where the product is published (e.g., "web", "global")
139/// - `status` - Whether the product is active, archived, or draft
140/// - `tags` - A comma-separated list of tags
141/// - `template_suffix` - The suffix of the template used for this product
142///
143/// ## Read-Only Fields
144/// - `id` - The unique identifier
145/// - `handle` - The URL-friendly name
146/// - `created_at` - When the product was created
147/// - `updated_at` - When the product was last updated
148/// - `admin_graphql_api_id` - The GraphQL API ID
149///
150/// ## Nested Resources
151/// - `variants` - The product's variants
152/// - `options` - The product's options
153/// - `images` - The product's images
154/// - `image` - The main/featured image
155#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
156pub struct Product {
157    /// The unique identifier of the product.
158    /// Read-only field.
159    #[serde(skip_serializing)]
160    pub id: Option<u64>,
161
162    /// The name of the product.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub title: Option<String>,
165
166    /// The description of the product in HTML format.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub body_html: Option<String>,
169
170    /// The name of the product's vendor.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub vendor: Option<String>,
173
174    /// A categorization for the product.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub product_type: Option<String>,
177
178    /// The URL-friendly name of the product.
179    /// Read-only field - generated from the title.
180    #[serde(skip_serializing)]
181    pub handle: Option<String>,
182
183    /// When the product was created.
184    /// Read-only field.
185    #[serde(skip_serializing)]
186    pub created_at: Option<DateTime<Utc>>,
187
188    /// When the product was last updated.
189    /// Read-only field.
190    #[serde(skip_serializing)]
191    pub updated_at: Option<DateTime<Utc>>,
192
193    /// When the product was published.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub published_at: Option<DateTime<Utc>>,
196
197    /// Where the product is published.
198    /// Valid values: "web", "global".
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub published_scope: Option<String>,
201
202    /// The status of the product: active, archived, or draft.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub status: Option<ProductStatus>,
205
206    /// A comma-separated list of tags for the product.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub tags: Option<String>,
209
210    /// The suffix of the Liquid template used for the product page.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub template_suffix: Option<String>,
213
214    /// The admin GraphQL API ID for this product.
215    /// Read-only field.
216    #[serde(skip_serializing)]
217    pub admin_graphql_api_id: Option<String>,
218
219    /// The variants of the product.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub variants: Option<Vec<ProductVariant>>,
222
223    /// The options of the product (e.g., Size, Color).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub options: Option<Vec<ProductOption>>,
226
227    /// All images associated with the product.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub images: Option<Vec<ProductImage>>,
230
231    /// The main/featured image of the product.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub image: Option<ProductImage>,
234}
235
236impl RestResource for Product {
237    type Id = u64;
238    type FindParams = ProductFindParams;
239    type AllParams = ProductListParams;
240    type CountParams = ProductCountParams;
241
242    const NAME: &'static str = "Product";
243    const PLURAL: &'static str = "products";
244
245    const PATHS: &'static [ResourcePath] = &[
246        ResourcePath::new(
247            HttpMethod::Get,
248            ResourceOperation::Find,
249            &["id"],
250            "products/{id}",
251        ),
252        ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
253        ResourcePath::new(
254            HttpMethod::Get,
255            ResourceOperation::Count,
256            &[],
257            "products/count",
258        ),
259        ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "products"),
260        ResourcePath::new(
261            HttpMethod::Put,
262            ResourceOperation::Update,
263            &["id"],
264            "products/{id}",
265        ),
266        ResourcePath::new(
267            HttpMethod::Delete,
268            ResourceOperation::Delete,
269            &["id"],
270            "products/{id}",
271        ),
272    ];
273
274    fn get_id(&self) -> Option<Self::Id> {
275        self.id
276    }
277}
278
279/// Parameters for finding a single product.
280#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
281pub struct ProductFindParams {
282    /// Comma-separated list of fields to include in the response.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub fields: Option<String>,
285}
286
287/// Parameters for listing products.
288#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
289pub struct ProductListParams {
290    /// Return only products with the given IDs.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub ids: Option<Vec<u64>>,
293
294    /// Maximum number of results to return (default: 50, max: 250).
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub limit: Option<u32>,
297
298    /// Return products after this ID.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub since_id: Option<u64>,
301
302    /// Filter by product title.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub title: Option<String>,
305
306    /// Filter by product vendor.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub vendor: Option<String>,
309
310    /// Filter by product handle.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub handle: Option<String>,
313
314    /// Filter by product type.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub product_type: Option<String>,
317
318    /// Filter by collection ID.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub collection_id: Option<u64>,
321
322    /// Show products created after this date.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub created_at_min: Option<DateTime<Utc>>,
325
326    /// Show products created before this date.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub created_at_max: Option<DateTime<Utc>>,
329
330    /// Show products updated after this date.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub updated_at_min: Option<DateTime<Utc>>,
333
334    /// Show products updated before this date.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub updated_at_max: Option<DateTime<Utc>>,
337
338    /// Show products published after this date.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub published_at_min: Option<DateTime<Utc>>,
341
342    /// Show products published before this date.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub published_at_max: Option<DateTime<Utc>>,
345
346    /// Filter by published status.
347    /// Valid values: "published", "unpublished", "any".
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub published_status: Option<String>,
350
351    /// Filter by product status.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub status: Option<ProductStatus>,
354
355    /// Comma-separated list of fields to include in the response.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub fields: Option<String>,
358
359    /// Cursor for pagination.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub page_info: Option<String>,
362}
363
364/// Parameters for counting products.
365#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
366pub struct ProductCountParams {
367    /// Filter by product vendor.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub vendor: Option<String>,
370
371    /// Filter by product type.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub product_type: Option<String>,
374
375    /// Filter by collection ID.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub collection_id: Option<u64>,
378
379    /// Show products created after this date.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub created_at_min: Option<DateTime<Utc>>,
382
383    /// Show products created before this date.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub created_at_max: Option<DateTime<Utc>>,
386
387    /// Show products updated after this date.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub updated_at_min: Option<DateTime<Utc>>,
390
391    /// Show products updated before this date.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub updated_at_max: Option<DateTime<Utc>>,
394
395    /// Show products published after this date.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub published_at_min: Option<DateTime<Utc>>,
398
399    /// Show products published before this date.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub published_at_max: Option<DateTime<Utc>>,
402
403    /// Filter by published status.
404    /// Valid values: "published", "unpublished", "any".
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub published_status: Option<String>,
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::rest::{get_path, ResourceOperation};
413
414    #[test]
415    fn test_product_serialization_with_all_fields() {
416        let product = Product {
417            id: Some(12345), // Read-only, should be skipped in serialization
418            title: Some("Test Product".to_string()),
419            body_html: Some("<p>Description</p>".to_string()),
420            vendor: Some("Test Vendor".to_string()),
421            product_type: Some("T-Shirts".to_string()),
422            handle: Some("test-product".to_string()), // Read-only
423            created_at: Some(
424                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
425                    .unwrap()
426                    .with_timezone(&Utc),
427            ), // Read-only
428            updated_at: Some(
429                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
430                    .unwrap()
431                    .with_timezone(&Utc),
432            ), // Read-only
433            published_at: Some(
434                DateTime::parse_from_rfc3339("2024-01-20T12:00:00Z")
435                    .unwrap()
436                    .with_timezone(&Utc),
437            ),
438            published_scope: Some("global".to_string()),
439            status: Some(ProductStatus::Active),
440            tags: Some("summer, sale, featured".to_string()),
441            template_suffix: Some("custom".to_string()),
442            admin_graphql_api_id: Some("gid://shopify/Product/12345".to_string()), // Read-only
443            variants: Some(vec![ProductVariant {
444                id: Some(111),
445                title: Some("Default".to_string()),
446                price: Some("29.99".to_string()),
447                ..Default::default()
448            }]),
449            options: Some(vec![ProductOption {
450                name: Some("Size".to_string()),
451                values: Some(vec!["Small".to_string(), "Medium".to_string()]),
452                ..Default::default()
453            }]),
454            images: Some(vec![]),
455            image: None,
456        };
457
458        let json = serde_json::to_string(&product).unwrap();
459        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
460
461        // Writable fields should be present
462        assert_eq!(parsed["title"], "Test Product");
463        assert_eq!(parsed["body_html"], "<p>Description</p>");
464        assert_eq!(parsed["vendor"], "Test Vendor");
465        assert_eq!(parsed["product_type"], "T-Shirts");
466        assert_eq!(parsed["published_scope"], "global");
467        assert_eq!(parsed["status"], "active");
468        assert_eq!(parsed["tags"], "summer, sale, featured");
469        assert_eq!(parsed["template_suffix"], "custom");
470
471        // Read-only fields should be omitted
472        assert!(parsed.get("id").is_none());
473        assert!(parsed.get("handle").is_none());
474        assert!(parsed.get("created_at").is_none());
475        assert!(parsed.get("updated_at").is_none());
476        assert!(parsed.get("admin_graphql_api_id").is_none());
477    }
478
479    #[test]
480    fn test_product_deserialization_from_api_response() {
481        let json = r#"{
482            "id": 788032119674292922,
483            "title": "Example T-Shirt",
484            "body_html": "<strong>Good cotton T-shirt</strong>",
485            "vendor": "Acme",
486            "product_type": "Shirts",
487            "handle": "example-t-shirt",
488            "created_at": "2024-01-15T10:30:00Z",
489            "updated_at": "2024-06-20T15:45:00Z",
490            "published_at": "2024-01-20T12:00:00Z",
491            "published_scope": "global",
492            "status": "active",
493            "tags": "cotton, summer",
494            "template_suffix": null,
495            "admin_graphql_api_id": "gid://shopify/Product/788032119674292922",
496            "variants": [
497                {
498                    "id": 39072856,
499                    "product_id": 788032119674292922,
500                    "title": "Small",
501                    "price": "19.99",
502                    "compare_at_price": "24.99",
503                    "sku": "SHIRT-SM",
504                    "position": 1,
505                    "inventory_quantity": 100,
506                    "option1": "Small",
507                    "option2": null,
508                    "option3": null,
509                    "image_id": null,
510                    "created_at": "2024-01-15T10:30:00Z",
511                    "updated_at": "2024-06-20T15:45:00Z"
512                }
513            ],
514            "options": [
515                {
516                    "id": 594680422,
517                    "product_id": 788032119674292922,
518                    "name": "Size",
519                    "position": 1,
520                    "values": ["Small", "Medium", "Large"]
521                }
522            ],
523            "images": [],
524            "image": null
525        }"#;
526
527        let product: Product = serde_json::from_str(json).unwrap();
528
529        // Verify all fields are deserialized
530        assert_eq!(product.id, Some(788032119674292922));
531        assert_eq!(product.title, Some("Example T-Shirt".to_string()));
532        assert_eq!(
533            product.body_html,
534            Some("<strong>Good cotton T-shirt</strong>".to_string())
535        );
536        assert_eq!(product.vendor, Some("Acme".to_string()));
537        assert_eq!(product.product_type, Some("Shirts".to_string()));
538        assert_eq!(product.handle, Some("example-t-shirt".to_string()));
539        assert!(product.created_at.is_some());
540        assert!(product.updated_at.is_some());
541        assert!(product.published_at.is_some());
542        assert_eq!(product.published_scope, Some("global".to_string()));
543        assert_eq!(product.status, Some(ProductStatus::Active));
544        assert_eq!(product.tags, Some("cotton, summer".to_string()));
545        assert_eq!(product.template_suffix, None);
546        assert_eq!(
547            product.admin_graphql_api_id,
548            Some("gid://shopify/Product/788032119674292922".to_string())
549        );
550
551        // Verify nested variants
552        let variants = product.variants.unwrap();
553        assert_eq!(variants.len(), 1);
554        assert_eq!(variants[0].id, Some(39072856));
555        assert_eq!(variants[0].title, Some("Small".to_string()));
556        assert_eq!(variants[0].price, Some("19.99".to_string()));
557        assert_eq!(variants[0].compare_at_price, Some("24.99".to_string()));
558        assert_eq!(variants[0].sku, Some("SHIRT-SM".to_string()));
559        assert_eq!(variants[0].inventory_quantity, Some(100));
560
561        // Verify nested options
562        let options = product.options.unwrap();
563        assert_eq!(options.len(), 1);
564        assert_eq!(options[0].name, Some("Size".to_string()));
565        assert_eq!(
566            options[0].values,
567            Some(vec![
568                "Small".to_string(),
569                "Medium".to_string(),
570                "Large".to_string()
571            ])
572        );
573    }
574
575    #[test]
576    fn test_product_list_params_serialization() {
577        let params = ProductListParams {
578            ids: Some(vec![123, 456, 789]),
579            limit: Some(50),
580            vendor: Some("Acme".to_string()),
581            status: Some(ProductStatus::Active),
582            published_status: Some("published".to_string()),
583            ..Default::default()
584        };
585
586        let json = serde_json::to_value(&params).unwrap();
587
588        assert_eq!(json["ids"], serde_json::json!([123, 456, 789]));
589        assert_eq!(json["limit"], 50);
590        assert_eq!(json["vendor"], "Acme");
591        assert_eq!(json["status"], "active");
592        assert_eq!(json["published_status"], "published");
593
594        // Fields not set should be omitted
595        assert!(json.get("title").is_none());
596        assert!(json.get("handle").is_none());
597        assert!(json.get("created_at_min").is_none());
598    }
599
600    #[test]
601    fn test_product_status_enum_serialization() {
602        // Test serialization to lowercase
603        let active = ProductStatus::Active;
604        let archived = ProductStatus::Archived;
605        let draft = ProductStatus::Draft;
606
607        assert_eq!(serde_json::to_string(&active).unwrap(), "\"active\"");
608        assert_eq!(serde_json::to_string(&archived).unwrap(), "\"archived\"");
609        assert_eq!(serde_json::to_string(&draft).unwrap(), "\"draft\"");
610
611        // Test deserialization from lowercase
612        let active: ProductStatus = serde_json::from_str("\"active\"").unwrap();
613        let archived: ProductStatus = serde_json::from_str("\"archived\"").unwrap();
614        let draft: ProductStatus = serde_json::from_str("\"draft\"").unwrap();
615
616        assert_eq!(active, ProductStatus::Active);
617        assert_eq!(archived, ProductStatus::Archived);
618        assert_eq!(draft, ProductStatus::Draft);
619    }
620
621    #[test]
622    fn test_product_get_id_returns_correct_value() {
623        // Product with ID
624        let product_with_id = Product {
625            id: Some(123456789),
626            title: Some("Test".to_string()),
627            ..Default::default()
628        };
629        assert_eq!(product_with_id.get_id(), Some(123456789));
630
631        // Product without ID (new product)
632        let product_without_id = Product {
633            id: None,
634            title: Some("New Product".to_string()),
635            ..Default::default()
636        };
637        assert_eq!(product_without_id.get_id(), None);
638    }
639
640    #[test]
641    fn test_product_path_constants_are_correct() {
642        // Test Find path
643        let find_path = get_path(Product::PATHS, ResourceOperation::Find, &["id"]);
644        assert!(find_path.is_some());
645        assert_eq!(find_path.unwrap().template, "products/{id}");
646        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
647
648        // Test All path
649        let all_path = get_path(Product::PATHS, ResourceOperation::All, &[]);
650        assert!(all_path.is_some());
651        assert_eq!(all_path.unwrap().template, "products");
652        assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
653
654        // Test Count path
655        let count_path = get_path(Product::PATHS, ResourceOperation::Count, &[]);
656        assert!(count_path.is_some());
657        assert_eq!(count_path.unwrap().template, "products/count");
658        assert_eq!(count_path.unwrap().http_method, HttpMethod::Get);
659
660        // Test Create path
661        let create_path = get_path(Product::PATHS, ResourceOperation::Create, &[]);
662        assert!(create_path.is_some());
663        assert_eq!(create_path.unwrap().template, "products");
664        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
665
666        // Test Update path
667        let update_path = get_path(Product::PATHS, ResourceOperation::Update, &["id"]);
668        assert!(update_path.is_some());
669        assert_eq!(update_path.unwrap().template, "products/{id}");
670        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
671
672        // Test Delete path
673        let delete_path = get_path(Product::PATHS, ResourceOperation::Delete, &["id"]);
674        assert!(delete_path.is_some());
675        assert_eq!(delete_path.unwrap().template, "products/{id}");
676        assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
677
678        // Verify constants
679        assert_eq!(Product::NAME, "Product");
680        assert_eq!(Product::PLURAL, "products");
681    }
682
683    #[test]
684    fn test_product_variant_embedded_struct() {
685        let variant = ProductVariant {
686            id: Some(111222333),
687            product_id: Some(444555666),
688            title: Some("Large / Blue".to_string()),
689            price: Some("39.99".to_string()),
690            compare_at_price: Some("49.99".to_string()),
691            sku: Some("PROD-LG-BL".to_string()),
692            position: Some(2),
693            inventory_quantity: Some(50),
694            option1: Some("Large".to_string()),
695            option2: Some("Blue".to_string()),
696            option3: None,
697            image_id: Some(999888777),
698            created_at: Some(
699                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
700                    .unwrap()
701                    .with_timezone(&Utc),
702            ),
703            updated_at: Some(
704                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
705                    .unwrap()
706                    .with_timezone(&Utc),
707            ),
708        };
709
710        // Test serialization - read-only fields should be skipped
711        let json = serde_json::to_string(&variant).unwrap();
712        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
713
714        // Writable fields should be present
715        assert_eq!(parsed["product_id"], 444555666);
716        assert_eq!(parsed["title"], "Large / Blue");
717        assert_eq!(parsed["price"], "39.99");
718        assert_eq!(parsed["compare_at_price"], "49.99");
719        assert_eq!(parsed["sku"], "PROD-LG-BL");
720        assert_eq!(parsed["position"], 2);
721        assert_eq!(parsed["option1"], "Large");
722        assert_eq!(parsed["option2"], "Blue");
723        assert_eq!(parsed["image_id"], 999888777);
724
725        // Read-only fields should be omitted
726        assert!(parsed.get("id").is_none());
727        assert!(parsed.get("inventory_quantity").is_none());
728        assert!(parsed.get("created_at").is_none());
729        assert!(parsed.get("updated_at").is_none());
730    }
731
732    #[test]
733    fn test_product_count_params_serialization() {
734        let params = ProductCountParams {
735            vendor: Some("Acme".to_string()),
736            product_type: Some("Shirts".to_string()),
737            collection_id: Some(123456),
738            published_status: Some("published".to_string()),
739            ..Default::default()
740        };
741
742        let json = serde_json::to_value(&params).unwrap();
743
744        assert_eq!(json["vendor"], "Acme");
745        assert_eq!(json["product_type"], "Shirts");
746        assert_eq!(json["collection_id"], 123456);
747        assert_eq!(json["published_status"], "published");
748    }
749}