Skip to main content

shopify_sdk/rest/resources/v2025_10/
fulfillment.rs

1//! Fulfillment resource implementation.
2//!
3//! This module provides the [`Fulfillment`] resource for managing fulfillments in Shopify.
4//! Fulfillments represent shipments of order line items to customers.
5//!
6//! # Nested Resource
7//!
8//! Fulfillments are primarily nested under orders:
9//! - `/orders/{order_id}/fulfillments` - List fulfillments for an order
10//! - `/orders/{order_id}/fulfillments/{id}` - Get a specific fulfillment
11//!
12//! # Resource-Specific Operations
13//!
14//! In addition to standard CRUD operations, the Fulfillment resource provides:
15//! - [`Fulfillment::cancel`] - Cancel a fulfillment
16//! - [`Fulfillment::update_tracking`] - Update tracking information
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use shopify_sdk::rest::{RestResource, ResourceResponse};
22//! use shopify_sdk::rest::resources::v2025_10::{
23//!     Fulfillment, FulfillmentListParams, FulfillmentStatus, TrackingInfo
24//! };
25//!
26//! // List fulfillments for an order
27//! let fulfillments = Fulfillment::all_with_parent(&client, "order_id", 123, None).await?;
28//!
29//! // Cancel a fulfillment
30//! let cancelled = fulfillment.cancel(&client).await?;
31//!
32//! // Update tracking information
33//! let tracking = TrackingInfo {
34//!     tracking_number: Some("1Z999AA10123456784".to_string()),
35//!     tracking_url: Some("https://ups.com/tracking/1Z999AA10123456784".to_string()),
36//!     tracking_company: Some("UPS".to_string()),
37//! };
38//! let updated = fulfillment.update_tracking(&client, tracking).await?;
39//! ```
40
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43
44use crate::clients::RestClient;
45use crate::rest::{ResourceError, ResourceOperation, ResourcePath, RestResource};
46use crate::HttpMethod;
47
48use super::common::Address;
49
50/// The status of a fulfillment.
51///
52/// Indicates the current state of the fulfillment process.
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum FulfillmentStatus {
56    /// The fulfillment is pending.
57    #[default]
58    Pending,
59    /// The fulfillment is open and in progress.
60    Open,
61    /// The fulfillment was successful.
62    Success,
63    /// The fulfillment was cancelled.
64    Cancelled,
65    /// There was an error with the fulfillment.
66    Error,
67    /// The fulfillment failed.
68    Failure,
69}
70
71/// The shipment status of a fulfillment.
72///
73/// Indicates the shipping/delivery status of the fulfillment.
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum ShipmentStatus {
77    /// A label has been printed for the shipment.
78    LabelPrinted,
79    /// A label has been purchased for the shipment.
80    LabelPurchased,
81    /// Delivery was attempted but failed.
82    AttemptedDelivery,
83    /// The package is ready for pickup.
84    ReadyForPickup,
85    /// The shipment has been confirmed.
86    Confirmed,
87    /// The package is in transit.
88    InTransit,
89    /// The package is out for delivery.
90    OutForDelivery,
91    /// The package has been delivered.
92    Delivered,
93    /// The shipment failed.
94    Failure,
95}
96
97/// A line item included in a fulfillment.
98///
99/// Contains information about the product/variant being fulfilled.
100#[allow(clippy::derive_partial_eq_without_eq)]
101#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
102pub struct FulfillmentLineItem {
103    /// The unique identifier of the line item.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub id: Option<u64>,
106
107    /// The ID of the product variant.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub variant_id: Option<u64>,
110
111    /// The title of the product.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub title: Option<String>,
114
115    /// The quantity being fulfilled.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub quantity: Option<i64>,
118
119    /// The SKU of the variant.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub sku: Option<String>,
122
123    /// The title of the variant.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub variant_title: Option<String>,
126
127    /// The vendor of the product.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub vendor: Option<String>,
130
131    /// The fulfillment service for this item.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub fulfillment_service: Option<String>,
134
135    /// The ID of the product.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub product_id: Option<u64>,
138
139    /// Whether the item requires shipping.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub requires_shipping: Option<bool>,
142
143    /// Whether the item is taxable.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub taxable: Option<bool>,
146
147    /// Whether the item is a gift card.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub gift_card: Option<bool>,
150
151    /// The name of the product and variant.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub name: Option<String>,
154
155    /// The inventory management service.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub variant_inventory_management: Option<String>,
158
159    /// Custom properties on the line item.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub properties: Option<Vec<serde_json::Value>>,
162
163    /// Whether the product still exists.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub product_exists: Option<bool>,
166
167    /// The price per item as a string.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub price: Option<String>,
170
171    /// The total discount on this line item.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub total_discount: Option<String>,
174
175    /// The quantity that can still be fulfilled.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub fulfillable_quantity: Option<i64>,
178
179    /// The fulfillment status of this line item.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub fulfillment_status: Option<String>,
182}
183
184/// Tracking information for updating a fulfillment.
185///
186/// Used with the [`Fulfillment::update_tracking`] operation to update
187/// tracking details for a fulfillment.
188///
189/// # Example
190///
191/// ```rust
192/// use shopify_sdk::rest::resources::v2025_10::TrackingInfo;
193///
194/// let tracking = TrackingInfo {
195///     tracking_number: Some("1Z999AA10123456784".to_string()),
196///     tracking_url: Some("https://ups.com/tracking/1Z999AA10123456784".to_string()),
197///     tracking_company: Some("UPS".to_string()),
198/// };
199/// ```
200#[allow(clippy::derive_partial_eq_without_eq)]
201#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
202pub struct TrackingInfo {
203    /// The tracking number for the shipment.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub tracking_number: Option<String>,
206
207    /// The URL to track the shipment.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub tracking_url: Option<String>,
210
211    /// The name of the tracking company.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub tracking_company: Option<String>,
214}
215
216/// A fulfillment in Shopify.
217///
218/// Fulfillments represent the shipping of order line items to customers.
219/// Each fulfillment contains information about which items were shipped,
220/// tracking information, and the fulfillment status.
221///
222/// # Nested Resource
223///
224/// Fulfillments are accessed under orders:
225/// - `/orders/{order_id}/fulfillments/{id}`
226///
227/// Most operations require an `order_id`.
228///
229/// # Read-Only Fields
230///
231/// The following fields are read-only and will not be sent in create/update requests:
232/// - `id`, `order_id`, `name`
233/// - `created_at`, `updated_at`
234/// - `admin_graphql_api_id`
235///
236/// # Example
237///
238/// ```rust,ignore
239/// use shopify_sdk::rest::resources::v2025_10::Fulfillment;
240///
241/// // List fulfillments for an order
242/// let fulfillments = Fulfillment::all_with_parent(&client, "order_id", 123, None).await?;
243///
244/// // Cancel a fulfillment
245/// let cancelled = fulfillment.cancel(&client).await?;
246/// ```
247#[allow(clippy::derive_partial_eq_without_eq)]
248#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
249pub struct Fulfillment {
250    // --- Read-only fields (not serialized) ---
251    /// The unique identifier of the fulfillment.
252    #[serde(skip_serializing)]
253    pub id: Option<u64>,
254
255    /// The ID of the order this fulfillment belongs to.
256    #[serde(skip_serializing)]
257    pub order_id: Option<u64>,
258
259    /// The name of the fulfillment (e.g., "#1001.1").
260    #[serde(skip_serializing)]
261    pub name: Option<String>,
262
263    /// When the fulfillment was created.
264    #[serde(skip_serializing)]
265    pub created_at: Option<DateTime<Utc>>,
266
267    /// When the fulfillment was last updated.
268    #[serde(skip_serializing)]
269    pub updated_at: Option<DateTime<Utc>>,
270
271    /// The admin GraphQL API ID.
272    #[serde(skip_serializing)]
273    pub admin_graphql_api_id: Option<String>,
274
275    // --- Core fields ---
276    /// The status of the fulfillment.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub status: Option<FulfillmentStatus>,
279
280    /// The fulfillment service.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub service: Option<String>,
283
284    /// The ID of the location that fulfilled the order.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub location_id: Option<u64>,
287
288    /// The shipment status.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub shipment_status: Option<ShipmentStatus>,
291
292    /// The name of the tracking company.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub tracking_company: Option<String>,
295
296    /// The tracking number for the shipment.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub tracking_number: Option<String>,
299
300    /// Multiple tracking numbers (if any).
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub tracking_numbers: Option<Vec<String>>,
303
304    /// The URL to track the shipment.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub tracking_url: Option<String>,
307
308    /// Multiple tracking URLs (if any).
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub tracking_urls: Option<Vec<String>>,
311
312    /// The origin address for the shipment.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub origin_address: Option<Address>,
315
316    /// Line items included in this fulfillment.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub line_items: Option<Vec<FulfillmentLineItem>>,
319
320    /// Whether to notify the customer about this fulfillment.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub notify_customer: Option<bool>,
323
324    /// The variant inventory management service.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub variant_inventory_management: Option<String>,
327}
328
329impl RestResource for Fulfillment {
330    type Id = u64;
331    type FindParams = FulfillmentFindParams;
332    type AllParams = FulfillmentListParams;
333    type CountParams = FulfillmentCountParams;
334
335    const NAME: &'static str = "Fulfillment";
336    const PLURAL: &'static str = "fulfillments";
337
338    /// Paths for the Fulfillment resource.
339    ///
340    /// Fulfillments are primarily nested under orders, requiring `order_id`
341    /// for most operations.
342    const PATHS: &'static [ResourcePath] = &[
343        // Nested paths under orders
344        ResourcePath::new(
345            HttpMethod::Get,
346            ResourceOperation::Find,
347            &["order_id", "id"],
348            "orders/{order_id}/fulfillments/{id}",
349        ),
350        ResourcePath::new(
351            HttpMethod::Get,
352            ResourceOperation::All,
353            &["order_id"],
354            "orders/{order_id}/fulfillments",
355        ),
356        ResourcePath::new(
357            HttpMethod::Get,
358            ResourceOperation::Count,
359            &["order_id"],
360            "orders/{order_id}/fulfillments/count",
361        ),
362        ResourcePath::new(
363            HttpMethod::Post,
364            ResourceOperation::Create,
365            &["order_id"],
366            "orders/{order_id}/fulfillments",
367        ),
368        ResourcePath::new(
369            HttpMethod::Put,
370            ResourceOperation::Update,
371            &["order_id", "id"],
372            "orders/{order_id}/fulfillments/{id}",
373        ),
374    ];
375
376    fn get_id(&self) -> Option<Self::Id> {
377        self.id
378    }
379}
380
381impl Fulfillment {
382    /// Cancels the fulfillment.
383    ///
384    /// Sends a POST request to `/admin/api/{version}/orders/{order_id}/fulfillments/{id}/cancel.json`.
385    ///
386    /// # Arguments
387    ///
388    /// * `client` - The REST client to use for the request
389    ///
390    /// # Errors
391    ///
392    /// Returns [`ResourceError::NotFound`] if the fulfillment doesn't exist.
393    /// Returns [`ResourceError::PathResolutionFailed`] if the fulfillment has no ID or `order_id`.
394    ///
395    /// # Example
396    ///
397    /// ```rust,ignore
398    /// let fulfillment = Fulfillment::all_with_parent(&client, "order_id", 123, None).await?[0].clone();
399    /// let cancelled = fulfillment.cancel(&client).await?;
400    /// assert_eq!(cancelled.status, Some(FulfillmentStatus::Cancelled));
401    /// ```
402    pub async fn cancel(&self, client: &RestClient) -> Result<Self, ResourceError> {
403        let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
404            resource: Self::NAME,
405            operation: "cancel",
406        })?;
407
408        let order_id = self.order_id.ok_or(ResourceError::PathResolutionFailed {
409            resource: Self::NAME,
410            operation: "cancel",
411        })?;
412
413        let path = format!("orders/{order_id}/fulfillments/{id}/cancel");
414        let body = serde_json::json!({});
415
416        let response = client.post(&path, body, None).await?;
417
418        if !response.is_ok() {
419            return Err(ResourceError::from_http_response(
420                response.code,
421                &response.body,
422                Self::NAME,
423                Some(&id.to_string()),
424                response.request_id(),
425            ));
426        }
427
428        // Parse the response - Shopify returns the fulfillment wrapped in "fulfillment" key
429        let fulfillment: Self = response
430            .body
431            .get("fulfillment")
432            .ok_or_else(|| {
433                ResourceError::Http(crate::clients::HttpError::Response(
434                    crate::clients::HttpResponseError {
435                        code: response.code,
436                        message: "Missing 'fulfillment' in response".to_string(),
437                        error_reference: response.request_id().map(ToString::to_string),
438                    },
439                ))
440            })
441            .and_then(|v| {
442                serde_json::from_value(v.clone()).map_err(|e| {
443                    ResourceError::Http(crate::clients::HttpError::Response(
444                        crate::clients::HttpResponseError {
445                            code: response.code,
446                            message: format!("Failed to deserialize fulfillment: {e}"),
447                            error_reference: response.request_id().map(ToString::to_string),
448                        },
449                    ))
450                })
451            })?;
452
453        Ok(fulfillment)
454    }
455
456    /// Updates tracking information for the fulfillment.
457    ///
458    /// Sends a POST request to `/admin/api/{version}/fulfillments/{id}/update_tracking.json`.
459    ///
460    /// Note: Unlike other fulfillment operations, `update_tracking` uses a standalone path
461    /// that only requires the fulfillment ID, not the order ID.
462    ///
463    /// # Arguments
464    ///
465    /// * `client` - The REST client to use for the request
466    /// * `tracking_info` - The new tracking information
467    ///
468    /// # Errors
469    ///
470    /// Returns [`ResourceError::NotFound`] if the fulfillment doesn't exist.
471    /// Returns [`ResourceError::PathResolutionFailed`] if the fulfillment has no ID.
472    ///
473    /// # Example
474    ///
475    /// ```rust,ignore
476    /// use shopify_sdk::rest::resources::v2025_10::TrackingInfo;
477    ///
478    /// let tracking = TrackingInfo {
479    ///     tracking_number: Some("1Z999AA10123456784".to_string()),
480    ///     tracking_url: Some("https://ups.com/tracking/1Z999AA10123456784".to_string()),
481    ///     tracking_company: Some("UPS".to_string()),
482    /// };
483    ///
484    /// let updated = fulfillment.update_tracking(&client, tracking).await?;
485    /// assert_eq!(updated.tracking_number, Some("1Z999AA10123456784".to_string()));
486    /// ```
487    pub async fn update_tracking(
488        &self,
489        client: &RestClient,
490        tracking_info: TrackingInfo,
491    ) -> Result<Self, ResourceError> {
492        let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
493            resource: Self::NAME,
494            operation: "update_tracking",
495        })?;
496
497        // update_tracking uses a standalone path - doesn't require order_id
498        let path = format!("fulfillments/{id}/update_tracking");
499
500        let body = serde_json::json!({
501            "fulfillment": {
502                "tracking_info": tracking_info
503            }
504        });
505
506        let response = client.post(&path, body, None).await?;
507
508        if !response.is_ok() {
509            return Err(ResourceError::from_http_response(
510                response.code,
511                &response.body,
512                Self::NAME,
513                Some(&id.to_string()),
514                response.request_id(),
515            ));
516        }
517
518        // Parse the response - Shopify returns the fulfillment wrapped in "fulfillment" key
519        let fulfillment: Self = response
520            .body
521            .get("fulfillment")
522            .ok_or_else(|| {
523                ResourceError::Http(crate::clients::HttpError::Response(
524                    crate::clients::HttpResponseError {
525                        code: response.code,
526                        message: "Missing 'fulfillment' in response".to_string(),
527                        error_reference: response.request_id().map(ToString::to_string),
528                    },
529                ))
530            })
531            .and_then(|v| {
532                serde_json::from_value(v.clone()).map_err(|e| {
533                    ResourceError::Http(crate::clients::HttpError::Response(
534                        crate::clients::HttpResponseError {
535                            code: response.code,
536                            message: format!("Failed to deserialize fulfillment: {e}"),
537                            error_reference: response.request_id().map(ToString::to_string),
538                        },
539                    ))
540                })
541            })?;
542
543        Ok(fulfillment)
544    }
545}
546
547/// Parameters for finding a single fulfillment.
548#[allow(clippy::derive_partial_eq_without_eq)]
549#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
550pub struct FulfillmentFindParams {
551    /// Comma-separated list of fields to include in the response.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub fields: Option<String>,
554}
555
556/// Parameters for listing fulfillments.
557///
558/// All fields are optional. Unset fields will not be included in the request.
559#[allow(clippy::derive_partial_eq_without_eq)]
560#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
561pub struct FulfillmentListParams {
562    /// Maximum number of results to return (default: 50, max: 250).
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub limit: Option<u32>,
565
566    /// Return only fulfillments after the specified ID.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub since_id: Option<u64>,
569
570    /// Show fulfillments created at or after this date.
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub created_at_min: Option<DateTime<Utc>>,
573
574    /// Show fulfillments created at or before this date.
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub created_at_max: Option<DateTime<Utc>>,
577
578    /// Show fulfillments last updated at or after this date.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub updated_at_min: Option<DateTime<Utc>>,
581
582    /// Show fulfillments last updated at or before this date.
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub updated_at_max: Option<DateTime<Utc>>,
585
586    /// Comma-separated list of fields to include in the response.
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub fields: Option<String>,
589
590    /// Page info for cursor-based pagination.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub page_info: Option<String>,
593}
594
595/// Parameters for counting fulfillments.
596#[allow(clippy::derive_partial_eq_without_eq)]
597#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
598pub struct FulfillmentCountParams {
599    /// Show fulfillments created at or after this date.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub created_at_min: Option<DateTime<Utc>>,
602
603    /// Show fulfillments created at or before this date.
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub created_at_max: Option<DateTime<Utc>>,
606
607    /// Show fulfillments last updated at or after this date.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub updated_at_min: Option<DateTime<Utc>>,
610
611    /// Show fulfillments last updated at or before this date.
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub updated_at_max: Option<DateTime<Utc>>,
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::rest::{get_path, ResourceOperation};
620
621    #[test]
622    fn test_fulfillment_struct_serialization() {
623        let fulfillment = Fulfillment {
624            id: Some(123456789),
625            order_id: Some(987654321),
626            name: Some("#1001.1".to_string()),
627            status: Some(FulfillmentStatus::Success),
628            service: Some("manual".to_string()),
629            location_id: Some(111222333),
630            shipment_status: Some(ShipmentStatus::Delivered),
631            tracking_company: Some("UPS".to_string()),
632            tracking_number: Some("1Z999AA10123456784".to_string()),
633            tracking_numbers: Some(vec!["1Z999AA10123456784".to_string()]),
634            tracking_url: Some("https://ups.com/tracking/1Z999AA10123456784".to_string()),
635            tracking_urls: Some(vec![
636                "https://ups.com/tracking/1Z999AA10123456784".to_string()
637            ]),
638            notify_customer: Some(true),
639            ..Default::default()
640        };
641
642        let json = serde_json::to_string(&fulfillment).unwrap();
643        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
644
645        // Writable fields should be present
646        assert_eq!(parsed["status"], "success");
647        assert_eq!(parsed["service"], "manual");
648        assert_eq!(parsed["location_id"], 111222333);
649        assert_eq!(parsed["shipment_status"], "delivered");
650        assert_eq!(parsed["tracking_company"], "UPS");
651        assert_eq!(parsed["tracking_number"], "1Z999AA10123456784");
652        assert_eq!(parsed["notify_customer"], true);
653
654        // Read-only fields should NOT be serialized
655        assert!(parsed.get("id").is_none());
656        assert!(parsed.get("order_id").is_none());
657        assert!(parsed.get("name").is_none());
658        assert!(parsed.get("created_at").is_none());
659        assert!(parsed.get("updated_at").is_none());
660        assert!(parsed.get("admin_graphql_api_id").is_none());
661    }
662
663    #[test]
664    fn test_fulfillment_deserialization_from_api_response() {
665        // Use r##"..."## to allow # inside the string
666        let json_str = r##"{
667            "id": 255858046,
668            "order_id": 450789469,
669            "name": "#1001.1",
670            "status": "success",
671            "service": "manual",
672            "location_id": 487838322,
673            "created_at": "2024-01-15T10:30:00Z",
674            "updated_at": "2024-06-20T15:45:00Z",
675            "shipment_status": "in_transit",
676            "tracking_company": "USPS",
677            "tracking_number": "9400111899223456789012",
678            "tracking_numbers": ["9400111899223456789012"],
679            "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=9400111899223456789012",
680            "tracking_urls": ["https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=9400111899223456789012"],
681            "line_items": [
682                {
683                    "id": 669751112,
684                    "variant_id": 457924702,
685                    "product_id": 632910392,
686                    "title": "IPod Nano - 8GB",
687                    "quantity": 1,
688                    "sku": "IPOD2008BLACK",
689                    "requires_shipping": true
690                }
691            ],
692            "admin_graphql_api_id": "gid://shopify/Fulfillment/255858046"
693        }"##;
694
695        let fulfillment: Fulfillment = serde_json::from_str(json_str).unwrap();
696
697        assert_eq!(fulfillment.id, Some(255858046));
698        assert_eq!(fulfillment.order_id, Some(450789469));
699        assert_eq!(fulfillment.name.as_deref(), Some("#1001.1"));
700        assert_eq!(fulfillment.status, Some(FulfillmentStatus::Success));
701        assert_eq!(fulfillment.service.as_deref(), Some("manual"));
702        assert_eq!(fulfillment.location_id, Some(487838322));
703        assert_eq!(fulfillment.shipment_status, Some(ShipmentStatus::InTransit));
704        assert_eq!(fulfillment.tracking_company.as_deref(), Some("USPS"));
705        assert_eq!(
706            fulfillment.tracking_number.as_deref(),
707            Some("9400111899223456789012")
708        );
709        assert!(fulfillment.created_at.is_some());
710        assert!(fulfillment.updated_at.is_some());
711
712        // Check line items
713        let line_items = fulfillment.line_items.unwrap();
714        assert_eq!(line_items.len(), 1);
715        assert_eq!(line_items[0].id, Some(669751112));
716        assert_eq!(line_items[0].title.as_deref(), Some("IPod Nano - 8GB"));
717        assert_eq!(line_items[0].quantity, Some(1));
718    }
719
720    #[test]
721    fn test_fulfillment_status_enum_serialization() {
722        // Test serialization to snake_case
723        let pending_str = serde_json::to_string(&FulfillmentStatus::Pending).unwrap();
724        assert_eq!(pending_str, "\"pending\"");
725
726        let open_str = serde_json::to_string(&FulfillmentStatus::Open).unwrap();
727        assert_eq!(open_str, "\"open\"");
728
729        let success_str = serde_json::to_string(&FulfillmentStatus::Success).unwrap();
730        assert_eq!(success_str, "\"success\"");
731
732        let cancelled_str = serde_json::to_string(&FulfillmentStatus::Cancelled).unwrap();
733        assert_eq!(cancelled_str, "\"cancelled\"");
734
735        let error_str = serde_json::to_string(&FulfillmentStatus::Error).unwrap();
736        assert_eq!(error_str, "\"error\"");
737
738        let failure_str = serde_json::to_string(&FulfillmentStatus::Failure).unwrap();
739        assert_eq!(failure_str, "\"failure\"");
740
741        // Test deserialization
742        let success: FulfillmentStatus = serde_json::from_str("\"success\"").unwrap();
743        let cancelled: FulfillmentStatus = serde_json::from_str("\"cancelled\"").unwrap();
744
745        assert_eq!(success, FulfillmentStatus::Success);
746        assert_eq!(cancelled, FulfillmentStatus::Cancelled);
747
748        // Test default
749        assert_eq!(FulfillmentStatus::default(), FulfillmentStatus::Pending);
750    }
751
752    #[test]
753    fn test_shipment_status_enum_serialization() {
754        // Test serialization to snake_case
755        let label_printed = serde_json::to_string(&ShipmentStatus::LabelPrinted).unwrap();
756        assert_eq!(label_printed, "\"label_printed\"");
757
758        let label_purchased = serde_json::to_string(&ShipmentStatus::LabelPurchased).unwrap();
759        assert_eq!(label_purchased, "\"label_purchased\"");
760
761        let attempted = serde_json::to_string(&ShipmentStatus::AttemptedDelivery).unwrap();
762        assert_eq!(attempted, "\"attempted_delivery\"");
763
764        let ready = serde_json::to_string(&ShipmentStatus::ReadyForPickup).unwrap();
765        assert_eq!(ready, "\"ready_for_pickup\"");
766
767        let confirmed = serde_json::to_string(&ShipmentStatus::Confirmed).unwrap();
768        assert_eq!(confirmed, "\"confirmed\"");
769
770        let in_transit = serde_json::to_string(&ShipmentStatus::InTransit).unwrap();
771        assert_eq!(in_transit, "\"in_transit\"");
772
773        let out_for_delivery = serde_json::to_string(&ShipmentStatus::OutForDelivery).unwrap();
774        assert_eq!(out_for_delivery, "\"out_for_delivery\"");
775
776        let delivered = serde_json::to_string(&ShipmentStatus::Delivered).unwrap();
777        assert_eq!(delivered, "\"delivered\"");
778
779        let failure = serde_json::to_string(&ShipmentStatus::Failure).unwrap();
780        assert_eq!(failure, "\"failure\"");
781
782        // Test deserialization
783        let in_transit_val: ShipmentStatus = serde_json::from_str("\"in_transit\"").unwrap();
784        let delivered_val: ShipmentStatus = serde_json::from_str("\"delivered\"").unwrap();
785        let out_for_delivery_val: ShipmentStatus =
786            serde_json::from_str("\"out_for_delivery\"").unwrap();
787
788        assert_eq!(in_transit_val, ShipmentStatus::InTransit);
789        assert_eq!(delivered_val, ShipmentStatus::Delivered);
790        assert_eq!(out_for_delivery_val, ShipmentStatus::OutForDelivery);
791    }
792
793    #[test]
794    fn test_nested_path_under_orders() {
795        // Test Find path (requires both order_id and id)
796        let find_path = get_path(
797            Fulfillment::PATHS,
798            ResourceOperation::Find,
799            &["order_id", "id"],
800        );
801        assert!(find_path.is_some());
802        assert_eq!(
803            find_path.unwrap().template,
804            "orders/{order_id}/fulfillments/{id}"
805        );
806        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
807
808        // Test All path (requires order_id)
809        let all_path = get_path(Fulfillment::PATHS, ResourceOperation::All, &["order_id"]);
810        assert!(all_path.is_some());
811        assert_eq!(all_path.unwrap().template, "orders/{order_id}/fulfillments");
812
813        // Test Count path (requires order_id)
814        let count_path = get_path(Fulfillment::PATHS, ResourceOperation::Count, &["order_id"]);
815        assert!(count_path.is_some());
816        assert_eq!(
817            count_path.unwrap().template,
818            "orders/{order_id}/fulfillments/count"
819        );
820
821        // Test Create path (requires order_id)
822        let create_path = get_path(Fulfillment::PATHS, ResourceOperation::Create, &["order_id"]);
823        assert!(create_path.is_some());
824        assert_eq!(
825            create_path.unwrap().template,
826            "orders/{order_id}/fulfillments"
827        );
828        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
829
830        // Test Update path (requires both order_id and id)
831        let update_path = get_path(
832            Fulfillment::PATHS,
833            ResourceOperation::Update,
834            &["order_id", "id"],
835        );
836        assert!(update_path.is_some());
837        assert_eq!(
838            update_path.unwrap().template,
839            "orders/{order_id}/fulfillments/{id}"
840        );
841        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
842
843        // Test that paths without order_id fail for All
844        let no_order_all = get_path(Fulfillment::PATHS, ResourceOperation::All, &[]);
845        assert!(no_order_all.is_none());
846
847        // Verify constants
848        assert_eq!(Fulfillment::NAME, "Fulfillment");
849        assert_eq!(Fulfillment::PLURAL, "fulfillments");
850    }
851
852    #[test]
853    fn test_resource_specific_operations_signatures() {
854        // This test verifies that the cancel and update_tracking methods exist
855        // with the correct signatures. The actual HTTP calls would require
856        // a mock client, but we verify the type signatures compile correctly.
857
858        // Verify cancel signature
859        fn _assert_cancel_signature<F, Fut>(f: F)
860        where
861            F: Fn(&Fulfillment, &RestClient) -> Fut,
862            Fut: std::future::Future<Output = Result<Fulfillment, ResourceError>>,
863        {
864            let _ = f;
865        }
866
867        // Verify update_tracking signature
868        fn _assert_update_tracking_signature<F, Fut>(f: F)
869        where
870            F: Fn(&Fulfillment, &RestClient, TrackingInfo) -> Fut,
871            Fut: std::future::Future<Output = Result<Fulfillment, ResourceError>>,
872        {
873            let _ = f;
874        }
875
876        // Verify PathResolutionFailed error is returned when fulfillment has no ID
877        let fulfillment_without_id = Fulfillment::default();
878        assert!(fulfillment_without_id.get_id().is_none());
879
880        // Verify TrackingInfo struct
881        let tracking = TrackingInfo {
882            tracking_number: Some("1Z999AA10123456784".to_string()),
883            tracking_url: Some("https://ups.com/tracking".to_string()),
884            tracking_company: Some("UPS".to_string()),
885        };
886        assert_eq!(
887            tracking.tracking_number,
888            Some("1Z999AA10123456784".to_string())
889        );
890
891        // Verify get_id returns correct value
892        let fulfillment_with_id = Fulfillment {
893            id: Some(255858046),
894            order_id: Some(450789469),
895            ..Default::default()
896        };
897        assert_eq!(fulfillment_with_id.get_id(), Some(255858046));
898    }
899
900    #[test]
901    fn test_fulfillment_list_params_serialization() {
902        let created_at_min = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
903            .unwrap()
904            .with_timezone(&Utc);
905
906        let params = FulfillmentListParams {
907            limit: Some(50),
908            since_id: Some(12345),
909            created_at_min: Some(created_at_min),
910            created_at_max: None,
911            updated_at_min: None,
912            updated_at_max: None,
913            fields: Some("id,status,tracking_number".to_string()),
914            page_info: None,
915        };
916
917        let json = serde_json::to_value(&params).unwrap();
918
919        assert_eq!(json["limit"], 50);
920        assert_eq!(json["since_id"], 12345);
921        assert_eq!(json["fields"], "id,status,tracking_number");
922        assert!(json["created_at_min"].as_str().is_some());
923
924        // Verify None fields are not serialized
925        assert!(json.get("created_at_max").is_none());
926        assert!(json.get("updated_at_min").is_none());
927        assert!(json.get("page_info").is_none());
928
929        // Test empty params
930        let empty_params = FulfillmentListParams::default();
931        let empty_json = serde_json::to_value(&empty_params).unwrap();
932        assert_eq!(empty_json, serde_json::json!({}));
933    }
934
935    #[test]
936    fn test_tracking_info_serialization() {
937        let tracking = TrackingInfo {
938            tracking_number: Some("1Z999AA10123456784".to_string()),
939            tracking_url: Some("https://ups.com/tracking/1Z999AA10123456784".to_string()),
940            tracking_company: Some("UPS".to_string()),
941        };
942
943        let json = serde_json::to_string(&tracking).unwrap();
944        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
945
946        assert_eq!(parsed["tracking_number"], "1Z999AA10123456784");
947        assert_eq!(
948            parsed["tracking_url"],
949            "https://ups.com/tracking/1Z999AA10123456784"
950        );
951        assert_eq!(parsed["tracking_company"], "UPS");
952
953        // Test partial tracking info
954        let partial_tracking = TrackingInfo {
955            tracking_number: Some("12345".to_string()),
956            ..Default::default()
957        };
958
959        let partial_json = serde_json::to_value(&partial_tracking).unwrap();
960        assert_eq!(partial_json["tracking_number"], "12345");
961        assert!(partial_json.get("tracking_url").is_none());
962        assert!(partial_json.get("tracking_company").is_none());
963    }
964
965    #[test]
966    fn test_fulfillment_line_item_serialization() {
967        let line_item = FulfillmentLineItem {
968            id: Some(669751112),
969            variant_id: Some(457924702),
970            product_id: Some(632910392),
971            title: Some("IPod Nano - 8GB".to_string()),
972            quantity: Some(2),
973            sku: Some("IPOD2008BLACK".to_string()),
974            variant_title: Some("Black".to_string()),
975            vendor: Some("Apple".to_string()),
976            fulfillment_service: Some("manual".to_string()),
977            requires_shipping: Some(true),
978            taxable: Some(true),
979            gift_card: Some(false),
980            name: Some("IPod Nano - 8GB - Black".to_string()),
981            product_exists: Some(true),
982            price: Some("199.00".to_string()),
983            total_discount: Some("0.00".to_string()),
984            fulfillable_quantity: Some(0),
985            fulfillment_status: Some("fulfilled".to_string()),
986            ..Default::default()
987        };
988
989        let json = serde_json::to_string(&line_item).unwrap();
990        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
991
992        assert_eq!(parsed["id"], 669751112);
993        assert_eq!(parsed["variant_id"], 457924702);
994        assert_eq!(parsed["title"], "IPod Nano - 8GB");
995        assert_eq!(parsed["quantity"], 2);
996        assert_eq!(parsed["sku"], "IPOD2008BLACK");
997        assert_eq!(parsed["price"], "199.00");
998        assert_eq!(parsed["requires_shipping"], true);
999    }
1000
1001    #[test]
1002    fn test_fulfillment_with_origin_address() {
1003        let fulfillment = Fulfillment {
1004            id: Some(123),
1005            order_id: Some(456),
1006            status: Some(FulfillmentStatus::Success),
1007            origin_address: Some(Address {
1008                first_name: Some("Warehouse".to_string()),
1009                address1: Some("123 Fulfillment Center".to_string()),
1010                city: Some("Los Angeles".to_string()),
1011                province: Some("California".to_string()),
1012                province_code: Some("CA".to_string()),
1013                country: Some("United States".to_string()),
1014                country_code: Some("US".to_string()),
1015                zip: Some("90001".to_string()),
1016                ..Default::default()
1017            }),
1018            ..Default::default()
1019        };
1020
1021        let json = serde_json::to_string(&fulfillment).unwrap();
1022        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1023
1024        // origin_address should be present
1025        assert!(parsed.get("origin_address").is_some());
1026        assert_eq!(parsed["origin_address"]["city"], "Los Angeles");
1027        assert_eq!(parsed["origin_address"]["province_code"], "CA");
1028    }
1029}