Skip to main content

shopify_sdk/webhooks/
registry.rs

1//! Webhook registry for managing webhook registrations.
2//!
3//! This module provides the [`WebhookRegistry`] struct for storing and managing
4//! webhook registrations locally, then syncing them with Shopify via GraphQL API.
5//!
6//! # Example
7//!
8//! ```rust
9//! use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
10//! use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
11//!
12//! let mut registry = WebhookRegistry::new();
13//!
14//! // Add registrations with HTTP delivery
15//! registry.add_registration(
16//!     WebhookRegistrationBuilder::new(
17//!         WebhookTopic::OrdersCreate,
18//!         WebhookDeliveryMethod::Http {
19//!             uri: "https://example.com/webhooks/orders/create".to_string(),
20//!         },
21//!     )
22//!     .build()
23//! );
24//!
25//! // Add registrations with EventBridge delivery
26//! registry.add_registration(
27//!     WebhookRegistrationBuilder::new(
28//!         WebhookTopic::ProductsUpdate,
29//!         WebhookDeliveryMethod::EventBridge {
30//!             arn: "arn:aws:events:us-east-1::event-source/aws.partner/shopify.com/123/source".to_string(),
31//!         },
32//!     )
33//!     .build()
34//! );
35//!
36//! // Get a registration
37//! let registration = registry.get_registration(&WebhookTopic::OrdersCreate);
38//! assert!(registration.is_some());
39//! ```
40
41use std::collections::HashMap;
42
43use crate::auth::Session;
44use crate::clients::GraphqlClient;
45use crate::config::ShopifyConfig;
46
47use super::errors::WebhookError;
48use super::types::{
49    WebhookDeliveryMethod, WebhookHandler, WebhookRegistration, WebhookRegistrationResult,
50    WebhookTopic,
51};
52use super::verification::{verify_webhook, WebhookRequest};
53
54/// Registry for managing webhook subscriptions.
55///
56/// `WebhookRegistry` stores webhook registrations in memory and provides
57/// methods to sync them with Shopify via the GraphQL Admin API.
58///
59/// # Two-Phase Pattern
60///
61/// The registry follows a two-phase pattern:
62///
63/// 1. **Add Registration (Local)**: Use [`add_registration`](Self::add_registration)
64///    to store webhook configuration in the in-memory registry
65/// 2. **Register with Shopify (Remote)**: Use [`register`](Self::register) or
66///    [`register_all`](Self::register_all) to sync registrations with Shopify
67///
68/// This pattern allows apps to configure webhooks at startup and register
69/// them later when a valid session is available.
70///
71/// # Thread Safety
72///
73/// `WebhookRegistry` is `Send + Sync`, making it safe to share across threads.
74///
75/// # Smart Registration
76///
77/// When registering webhooks, the registry performs "smart registration":
78/// - Queries existing subscriptions from Shopify
79/// - Compares configuration to detect changes
80/// - Only creates/updates when necessary
81/// - Avoids unnecessary API calls
82///
83/// # Delivery Methods
84///
85/// The registry supports three delivery methods:
86/// - **HTTP**: Webhooks delivered via HTTP POST to a callback URL
87/// - **EventBridge**: Webhooks delivered to Amazon EventBridge
88/// - **Pub/Sub**: Webhooks delivered to Google Cloud Pub/Sub
89///
90/// # Example
91///
92/// ```rust
93/// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
94/// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
95///
96/// // Create a registry and add registrations
97/// let mut registry = WebhookRegistry::new();
98///
99/// registry.add_registration(
100///     WebhookRegistrationBuilder::new(
101///         WebhookTopic::OrdersCreate,
102///         WebhookDeliveryMethod::Http {
103///             uri: "https://example.com/api/webhooks/orders".to_string(),
104///         },
105///     )
106///     .build()
107/// );
108///
109/// // Later, when you have a session:
110/// // let results = registry.register_all(&session, &config).await?;
111/// ```
112#[derive(Default)]
113pub struct WebhookRegistry {
114    /// Internal storage for webhook registrations, keyed by topic.
115    registrations: HashMap<WebhookTopic, WebhookRegistration>,
116    /// Internal storage for webhook handlers, keyed by topic.
117    handlers: HashMap<WebhookTopic, Box<dyn WebhookHandler>>,
118}
119
120// Implement Debug manually since trait objects don't implement Debug
121impl std::fmt::Debug for WebhookRegistry {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("WebhookRegistry")
124            .field("registrations", &self.registrations)
125            .field("handlers", &format!("<{} handlers>", self.handlers.len()))
126            .finish()
127    }
128}
129
130// Verify WebhookRegistry is Send + Sync at compile time
131const _: fn() = || {
132    const fn assert_send_sync<T: Send + Sync>() {}
133    assert_send_sync::<WebhookRegistry>();
134};
135
136impl WebhookRegistry {
137    /// Creates a new empty webhook registry.
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use shopify_sdk::webhooks::WebhookRegistry;
143    ///
144    /// let registry = WebhookRegistry::new();
145    /// assert!(registry.list_registrations().is_empty());
146    /// ```
147    #[must_use]
148    pub fn new() -> Self {
149        Self {
150            registrations: HashMap::new(),
151            handlers: HashMap::new(),
152        }
153    }
154
155    /// Adds a webhook registration to the registry.
156    ///
157    /// If a registration for the same topic already exists, it will be replaced.
158    /// If the registration contains a handler, the handler is extracted and stored
159    /// separately in the handlers map.
160    /// Returns `&mut Self` to allow method chaining.
161    ///
162    /// # Arguments
163    ///
164    /// * `registration` - The webhook registration to add
165    ///
166    /// # Example
167    ///
168    /// ```rust
169    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
170    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
171    ///
172    /// let mut registry = WebhookRegistry::new();
173    ///
174    /// // Method chaining with different delivery methods
175    /// registry
176    ///     .add_registration(
177    ///         WebhookRegistrationBuilder::new(
178    ///             WebhookTopic::OrdersCreate,
179    ///             WebhookDeliveryMethod::Http {
180    ///                 uri: "https://example.com/webhooks/orders/create".to_string(),
181    ///             },
182    ///         )
183    ///         .build()
184    ///     )
185    ///     .add_registration(
186    ///         WebhookRegistrationBuilder::new(
187    ///             WebhookTopic::ProductsUpdate,
188    ///             WebhookDeliveryMethod::EventBridge {
189    ///                 arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
190    ///             },
191    ///         )
192    ///         .build()
193    ///     );
194    ///
195    /// assert_eq!(registry.list_registrations().len(), 2);
196    /// ```
197    pub fn add_registration(&mut self, mut registration: WebhookRegistration) -> &mut Self {
198        let topic = registration.topic;
199
200        // Extract handler if present and store separately
201        if let Some(handler) = registration.handler.take() {
202            self.handlers.insert(topic, handler);
203        }
204
205        self.registrations.insert(topic, registration);
206        self
207    }
208
209    /// Gets a webhook registration by topic.
210    ///
211    /// Returns `None` if no registration exists for the given topic.
212    ///
213    /// # Arguments
214    ///
215    /// * `topic` - The webhook topic to look up
216    ///
217    /// # Example
218    ///
219    /// ```rust
220    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
221    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
222    ///
223    /// let mut registry = WebhookRegistry::new();
224    /// registry.add_registration(
225    ///     WebhookRegistrationBuilder::new(
226    ///         WebhookTopic::OrdersCreate,
227    ///         WebhookDeliveryMethod::Http {
228    ///             uri: "https://example.com/webhooks".to_string(),
229    ///         },
230    ///     )
231    ///     .build()
232    /// );
233    ///
234    /// // Found
235    /// assert!(registry.get_registration(&WebhookTopic::OrdersCreate).is_some());
236    ///
237    /// // Not found
238    /// assert!(registry.get_registration(&WebhookTopic::ProductsCreate).is_none());
239    /// ```
240    #[must_use]
241    pub fn get_registration(&self, topic: &WebhookTopic) -> Option<&WebhookRegistration> {
242        self.registrations.get(topic)
243    }
244
245    /// Lists all webhook registrations in the registry.
246    ///
247    /// Returns a vector of references to all registrations.
248    ///
249    /// # Example
250    ///
251    /// ```rust
252    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
253    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
254    ///
255    /// let mut registry = WebhookRegistry::new();
256    /// registry
257    ///     .add_registration(
258    ///         WebhookRegistrationBuilder::new(
259    ///             WebhookTopic::OrdersCreate,
260    ///             WebhookDeliveryMethod::Http {
261    ///                 uri: "https://example.com/webhooks/orders".to_string(),
262    ///             },
263    ///         )
264    ///         .build()
265    ///     )
266    ///     .add_registration(
267    ///         WebhookRegistrationBuilder::new(
268    ///             WebhookTopic::ProductsCreate,
269    ///             WebhookDeliveryMethod::PubSub {
270    ///                 project_id: "my-project".to_string(),
271    ///                 topic_id: "webhooks".to_string(),
272    ///             },
273    ///         )
274    ///         .build()
275    ///     );
276    ///
277    /// let registrations = registry.list_registrations();
278    /// assert_eq!(registrations.len(), 2);
279    /// ```
280    #[must_use]
281    pub fn list_registrations(&self) -> Vec<&WebhookRegistration> {
282        self.registrations.values().collect()
283    }
284
285    /// Processes an incoming webhook request.
286    ///
287    /// This method verifies the webhook signature, looks up the appropriate handler,
288    /// parses the payload, and invokes the handler.
289    ///
290    /// # Flow
291    ///
292    /// 1. Verify the webhook signature using [`verify_webhook`]
293    /// 2. Look up the handler by topic
294    /// 3. Parse the request body as JSON
295    /// 4. Invoke the handler with the context and payload
296    ///
297    /// # Arguments
298    ///
299    /// * `config` - The Shopify configuration containing the API secret key
300    /// * `request` - The incoming webhook request
301    ///
302    /// # Errors
303    ///
304    /// Returns `WebhookError::InvalidHmac` if signature verification fails.
305    /// Returns `WebhookError::NoHandlerForTopic` if no handler is registered for the topic.
306    /// Returns `WebhookError::PayloadParseError` if the body cannot be parsed as JSON.
307    /// Returns any error returned by the handler.
308    ///
309    /// # Example
310    ///
311    /// ```rust,ignore
312    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRequest};
313    ///
314    /// let registry = WebhookRegistry::new();
315    /// // ... add registrations with handlers ...
316    ///
317    /// // Process incoming webhook
318    /// registry.process(&config, &request).await?;
319    /// ```
320    pub async fn process(
321        &self,
322        config: &ShopifyConfig,
323        request: &WebhookRequest,
324    ) -> Result<(), WebhookError> {
325        // Step 1: Verify webhook signature and get context
326        let context = verify_webhook(config, request)?;
327
328        // Step 2: Look up handler by topic
329        let handler = match context.topic() {
330            Some(topic) => self.handlers.get(&topic),
331            None => None,
332        };
333
334        let handler = handler.ok_or_else(|| WebhookError::NoHandlerForTopic {
335            topic: context.topic_raw().to_string(),
336        })?;
337
338        // Step 3: Parse request body as JSON
339        let payload: serde_json::Value = serde_json::from_slice(request.body()).map_err(|e| {
340            WebhookError::PayloadParseError {
341                message: e.to_string(),
342            }
343        })?;
344
345        // Step 4: Invoke handler
346        handler.handle(context, payload).await
347    }
348
349    /// Registers a single webhook with Shopify.
350    ///
351    /// This method performs "smart registration":
352    /// - Queries existing subscriptions from Shopify
353    /// - Compares configuration to detect changes
354    /// - Creates new subscription if none exists
355    /// - Updates existing subscription if configuration differs
356    /// - Returns `AlreadyRegistered` if configuration matches
357    ///
358    /// # Arguments
359    ///
360    /// * `session` - The authenticated session for API calls
361    /// * `config` - The SDK configuration
362    /// * `topic` - The webhook topic to register
363    ///
364    /// # Errors
365    ///
366    /// Returns `WebhookError::RegistrationNotFound` if the topic is not in the registry.
367    /// Returns `WebhookError::GraphqlError` for underlying API errors.
368    /// Returns `WebhookError::ShopifyError` for userErrors in the response.
369    ///
370    /// # Example
371    ///
372    /// ```rust,ignore
373    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookDeliveryMethod};
374    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
375    ///
376    /// let mut registry = WebhookRegistry::new();
377    /// registry.add_registration(
378    ///     WebhookRegistrationBuilder::new(
379    ///         WebhookTopic::OrdersCreate,
380    ///         WebhookDeliveryMethod::Http {
381    ///             uri: "https://example.com/webhooks/orders".to_string(),
382    ///         },
383    ///     )
384    ///     .build()
385    /// );
386    ///
387    /// let result = registry.register(&session, &config, &WebhookTopic::OrdersCreate).await?;
388    /// ```
389    pub async fn register(
390        &self,
391        session: &Session,
392        config: &ShopifyConfig,
393        topic: &WebhookTopic,
394    ) -> Result<WebhookRegistrationResult, WebhookError> {
395        // Check that registration exists
396        let registration =
397            self.get_registration(topic)
398                .ok_or_else(|| WebhookError::RegistrationNotFound {
399                    topic: topic.clone(),
400                })?;
401
402        // Convert topic to GraphQL format
403        let graphql_topic = topic_to_graphql_format(topic);
404
405        // Create GraphQL client
406        let client = GraphqlClient::new(session, Some(config));
407
408        // Query existing webhook subscription
409        let existing = self
410            .query_existing_subscription(&client, &graphql_topic, &registration.delivery_method)
411            .await?;
412
413        match existing {
414            Some((id, existing_config)) => {
415                // Compare configurations
416                if self.config_matches(&existing_config, registration) {
417                    Ok(WebhookRegistrationResult::AlreadyRegistered { id })
418                } else {
419                    // Update existing subscription
420                    self.update_subscription(&client, &id, registration).await
421                }
422            }
423            None => {
424                // Create new subscription
425                self.create_subscription(&client, &graphql_topic, registration)
426                    .await
427            }
428        }
429    }
430
431    /// Registers all webhooks in the registry with Shopify.
432    ///
433    /// Iterates through all registrations and calls [`register`](Self::register) for each.
434    /// Continues processing even if individual registrations fail.
435    ///
436    /// # Arguments
437    ///
438    /// * `session` - The authenticated session for API calls
439    /// * `config` - The SDK configuration
440    ///
441    /// # Returns
442    ///
443    /// A vector of results for each registration.
444    /// Individual registration failures are captured in `WebhookRegistrationResult::Failed`.
445    ///
446    /// # Example
447    ///
448    /// ```rust,ignore
449    /// use shopify_sdk::webhooks::{WebhookRegistry, WebhookRegistrationBuilder, WebhookRegistrationResult, WebhookDeliveryMethod};
450    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
451    ///
452    /// let mut registry = WebhookRegistry::new();
453    /// registry.add_registration(/* ... */);
454    ///
455    /// let results = registry.register_all(&session, &config).await;
456    /// for result in results {
457    ///     match result {
458    ///         WebhookRegistrationResult::Created { id } => println!("Created: {}", id),
459    ///         WebhookRegistrationResult::Failed(err) => println!("Failed: {}", err),
460    ///         _ => {}
461    ///     }
462    /// }
463    /// ```
464    pub async fn register_all(
465        &self,
466        session: &Session,
467        config: &ShopifyConfig,
468    ) -> Vec<WebhookRegistrationResult> {
469        let mut results = Vec::new();
470
471        for registration in self.registrations.values() {
472            let result = match self.register(session, config, &registration.topic).await {
473                Ok(result) => result,
474                Err(error) => WebhookRegistrationResult::Failed(error),
475            };
476            results.push(result);
477        }
478
479        results
480    }
481
482    /// Unregisters a webhook from Shopify.
483    ///
484    /// Queries for the existing webhook subscription and deletes it.
485    ///
486    /// # Arguments
487    ///
488    /// * `session` - The authenticated session for API calls
489    /// * `config` - The SDK configuration
490    /// * `topic` - The webhook topic to unregister
491    ///
492    /// # Errors
493    ///
494    /// Returns `WebhookError::SubscriptionNotFound` if the webhook doesn't exist in Shopify.
495    /// Returns `WebhookError::GraphqlError` for underlying API errors.
496    /// Returns `WebhookError::ShopifyError` for userErrors in the response.
497    ///
498    /// # Example
499    ///
500    /// ```rust,ignore
501    /// use shopify_sdk::webhooks::WebhookRegistry;
502    /// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
503    ///
504    /// let registry = WebhookRegistry::new();
505    /// registry.unregister(&session, &config, &WebhookTopic::OrdersCreate).await?;
506    /// ```
507    pub async fn unregister(
508        &self,
509        session: &Session,
510        config: &ShopifyConfig,
511        topic: &WebhookTopic,
512    ) -> Result<(), WebhookError> {
513        // Get the registration to know the delivery method
514        let registration =
515            self.get_registration(topic)
516                .ok_or_else(|| WebhookError::RegistrationNotFound {
517                    topic: topic.clone(),
518                })?;
519
520        // Convert topic to GraphQL format
521        let graphql_topic = topic_to_graphql_format(topic);
522
523        // Create GraphQL client
524        let client = GraphqlClient::new(session, Some(config));
525
526        // Query existing webhook subscription
527        let existing = self
528            .query_existing_subscription(&client, &graphql_topic, &registration.delivery_method)
529            .await?;
530
531        match existing {
532            Some((id, _)) => {
533                // Delete the subscription
534                self.delete_subscription(&client, &id).await
535            }
536            None => Err(WebhookError::SubscriptionNotFound {
537                topic: topic.clone(),
538            }),
539        }
540    }
541
542    /// Unregisters all webhooks in the registry from Shopify.
543    ///
544    /// Iterates through all registrations and calls [`unregister`](Self::unregister) for each.
545    /// Continues processing even if individual unregistrations fail.
546    ///
547    /// # Arguments
548    ///
549    /// * `session` - The authenticated session for API calls
550    /// * `config` - The SDK configuration
551    ///
552    /// # Errors
553    ///
554    /// Returns the first error encountered, but continues processing all registrations.
555    ///
556    /// # Example
557    ///
558    /// ```rust,ignore
559    /// use shopify_sdk::webhooks::WebhookRegistry;
560    ///
561    /// let mut registry = WebhookRegistry::new();
562    /// // ... add registrations ...
563    ///
564    /// registry.unregister_all(&session, &config).await?;
565    /// ```
566    pub async fn unregister_all(
567        &self,
568        session: &Session,
569        config: &ShopifyConfig,
570    ) -> Result<(), WebhookError> {
571        let mut first_error: Option<WebhookError> = None;
572
573        for registration in self.registrations.values() {
574            if let Err(error) = self.unregister(session, config, &registration.topic).await {
575                if first_error.is_none() {
576                    first_error = Some(error);
577                }
578            }
579        }
580
581        match first_error {
582            Some(error) => Err(error),
583            None => Ok(()),
584        }
585    }
586
587    /// Queries Shopify for an existing webhook subscription by topic and delivery method.
588    async fn query_existing_subscription(
589        &self,
590        client: &GraphqlClient,
591        graphql_topic: &str,
592        delivery_method: &WebhookDeliveryMethod,
593    ) -> Result<Option<(String, ExistingWebhookConfig)>, WebhookError> {
594        let query = format!(
595            r#"
596            query {{
597                webhookSubscriptions(first: 25, topics: [{topic}]) {{
598                    edges {{
599                        node {{
600                            id
601                            endpoint {{
602                                ... on WebhookHttpEndpoint {{
603                                    callbackUrl
604                                }}
605                                ... on WebhookEventBridgeEndpoint {{
606                                    arn
607                                }}
608                                ... on WebhookPubSubEndpoint {{
609                                    pubSubProject
610                                    pubSubTopic
611                                }}
612                            }}
613                            includeFields
614                            metafieldNamespaces
615                            filter
616                        }}
617                    }}
618                }}
619            }}
620            "#,
621            topic = graphql_topic
622        );
623
624        let response = client.query(&query, None, None, None).await?;
625
626        // Parse the response
627        let edges = response.body["data"]["webhookSubscriptions"]["edges"]
628            .as_array()
629            .ok_or_else(|| WebhookError::ShopifyError {
630                message: "Invalid response structure".to_string(),
631            })?;
632
633        if edges.is_empty() {
634            return Ok(None);
635        }
636
637        // Find a matching subscription by delivery method
638        for edge in edges {
639            let node = &edge["node"];
640            let endpoint = &node["endpoint"];
641
642            // Parse endpoint and check if it matches the desired delivery method
643            let parsed_delivery_method = if let Some(uri) = endpoint["callbackUrl"].as_str() {
644                Some(WebhookDeliveryMethod::Http {
645                    uri: uri.to_string(),
646                })
647            } else if let Some(arn) = endpoint["arn"].as_str() {
648                Some(WebhookDeliveryMethod::EventBridge {
649                    arn: arn.to_string(),
650                })
651            } else if let (Some(project), Some(topic)) = (
652                endpoint["pubSubProject"].as_str(),
653                endpoint["pubSubTopic"].as_str(),
654            ) {
655                Some(WebhookDeliveryMethod::PubSub {
656                    project_id: project.to_string(),
657                    topic_id: topic.to_string(),
658                })
659            } else {
660                None
661            };
662
663            // Check if the delivery method type matches (we compare full method for exact match later)
664            if let Some(ref parsed_method) = parsed_delivery_method {
665                let type_matches = match (parsed_method, delivery_method) {
666                    (WebhookDeliveryMethod::Http { .. }, WebhookDeliveryMethod::Http { .. }) => {
667                        true
668                    }
669                    (
670                        WebhookDeliveryMethod::EventBridge { .. },
671                        WebhookDeliveryMethod::EventBridge { .. },
672                    ) => true,
673                    (
674                        WebhookDeliveryMethod::PubSub { .. },
675                        WebhookDeliveryMethod::PubSub { .. },
676                    ) => true,
677                    _ => false,
678                };
679
680                if type_matches {
681                    let id = node["id"]
682                        .as_str()
683                        .ok_or_else(|| WebhookError::ShopifyError {
684                            message: "Missing webhook ID".to_string(),
685                        })?
686                        .to_string();
687
688                    let include_fields = node["includeFields"].as_array().map(|arr| {
689                        arr.iter()
690                            .filter_map(|v| v.as_str().map(String::from))
691                            .collect()
692                    });
693
694                    let metafield_namespaces = node["metafieldNamespaces"].as_array().map(|arr| {
695                        arr.iter()
696                            .filter_map(|v| v.as_str().map(String::from))
697                            .collect()
698                    });
699
700                    let filter = node["filter"].as_str().map(String::from);
701
702                    return Ok(Some((
703                        id,
704                        ExistingWebhookConfig {
705                            delivery_method: parsed_method.clone(),
706                            include_fields,
707                            metafield_namespaces,
708                            filter,
709                        },
710                    )));
711                }
712            }
713        }
714
715        Ok(None)
716    }
717
718    /// Compares existing webhook configuration with desired configuration.
719    fn config_matches(
720        &self,
721        existing: &ExistingWebhookConfig,
722        registration: &WebhookRegistration,
723    ) -> bool {
724        existing.delivery_method == registration.delivery_method
725            && existing.include_fields == registration.include_fields
726            && existing.metafield_namespaces == registration.metafield_namespaces
727            && existing.filter == registration.filter
728    }
729
730    /// Creates a new webhook subscription in Shopify.
731    async fn create_subscription(
732        &self,
733        client: &GraphqlClient,
734        graphql_topic: &str,
735        registration: &WebhookRegistration,
736    ) -> Result<WebhookRegistrationResult, WebhookError> {
737        let delivery_input = build_delivery_input(&registration.delivery_method);
738
739        let include_fields_input = registration
740            .include_fields
741            .as_ref()
742            .map(|fields| {
743                let quoted: Vec<String> = fields.iter().map(|f| format!("\"{}\"", f)).collect();
744                format!(", includeFields: [{}]", quoted.join(", "))
745            })
746            .unwrap_or_default();
747
748        let metafield_namespaces_input = registration
749            .metafield_namespaces
750            .as_ref()
751            .map(|ns| {
752                let quoted: Vec<String> = ns.iter().map(|n| format!("\"{}\"", n)).collect();
753                format!(", metafieldNamespaces: [{}]", quoted.join(", "))
754            })
755            .unwrap_or_default();
756
757        let filter_input = registration
758            .filter
759            .as_ref()
760            .map(|f| format!(", filter: \"{}\"", f))
761            .unwrap_or_default();
762
763        let mutation = format!(
764            r#"
765            mutation {{
766                webhookSubscriptionCreate(
767                    topic: {topic},
768                    webhookSubscription: {{
769                        {delivery}{include_fields}{metafield_namespaces}{filter}
770                    }}
771                ) {{
772                    webhookSubscription {{
773                        id
774                    }}
775                    userErrors {{
776                        field
777                        message
778                    }}
779                }}
780            }}
781            "#,
782            topic = graphql_topic,
783            delivery = delivery_input,
784            include_fields = include_fields_input,
785            metafield_namespaces = metafield_namespaces_input,
786            filter = filter_input
787        );
788
789        let response = client.query(&mutation, None, None, None).await?;
790
791        // Check for userErrors
792        let user_errors = &response.body["data"]["webhookSubscriptionCreate"]["userErrors"];
793        if let Some(errors) = user_errors.as_array() {
794            if !errors.is_empty() {
795                let messages: Vec<String> = errors
796                    .iter()
797                    .filter_map(|e| e["message"].as_str().map(String::from))
798                    .collect();
799                return Err(WebhookError::ShopifyError {
800                    message: messages.join("; "),
801                });
802            }
803        }
804
805        // Get the created subscription ID
806        let id = response.body["data"]["webhookSubscriptionCreate"]["webhookSubscription"]["id"]
807            .as_str()
808            .ok_or_else(|| WebhookError::ShopifyError {
809                message: "Missing webhook subscription ID in response".to_string(),
810            })?
811            .to_string();
812
813        Ok(WebhookRegistrationResult::Created { id })
814    }
815
816    /// Updates an existing webhook subscription in Shopify.
817    async fn update_subscription(
818        &self,
819        client: &GraphqlClient,
820        id: &str,
821        registration: &WebhookRegistration,
822    ) -> Result<WebhookRegistrationResult, WebhookError> {
823        let delivery_input = build_delivery_input(&registration.delivery_method);
824
825        let include_fields_input = registration
826            .include_fields
827            .as_ref()
828            .map(|fields| {
829                let quoted: Vec<String> = fields.iter().map(|f| format!("\"{}\"", f)).collect();
830                format!(", includeFields: [{}]", quoted.join(", "))
831            })
832            .unwrap_or_default();
833
834        let metafield_namespaces_input = registration
835            .metafield_namespaces
836            .as_ref()
837            .map(|ns| {
838                let quoted: Vec<String> = ns.iter().map(|n| format!("\"{}\"", n)).collect();
839                format!(", metafieldNamespaces: [{}]", quoted.join(", "))
840            })
841            .unwrap_or_default();
842
843        let filter_input = registration
844            .filter
845            .as_ref()
846            .map(|f| format!(", filter: \"{}\"", f))
847            .unwrap_or_default();
848
849        let mutation = format!(
850            r#"
851            mutation {{
852                webhookSubscriptionUpdate(
853                    id: "{id}",
854                    webhookSubscription: {{
855                        {delivery}{include_fields}{metafield_namespaces}{filter}
856                    }}
857                ) {{
858                    webhookSubscription {{
859                        id
860                    }}
861                    userErrors {{
862                        field
863                        message
864                    }}
865                }}
866            }}
867            "#,
868            id = id,
869            delivery = delivery_input,
870            include_fields = include_fields_input,
871            metafield_namespaces = metafield_namespaces_input,
872            filter = filter_input
873        );
874
875        let response = client.query(&mutation, None, None, None).await?;
876
877        // Check for userErrors
878        let user_errors = &response.body["data"]["webhookSubscriptionUpdate"]["userErrors"];
879        if let Some(errors) = user_errors.as_array() {
880            if !errors.is_empty() {
881                let messages: Vec<String> = errors
882                    .iter()
883                    .filter_map(|e| e["message"].as_str().map(String::from))
884                    .collect();
885                return Err(WebhookError::ShopifyError {
886                    message: messages.join("; "),
887                });
888            }
889        }
890
891        Ok(WebhookRegistrationResult::Updated { id: id.to_string() })
892    }
893
894    /// Deletes a webhook subscription from Shopify.
895    async fn delete_subscription(
896        &self,
897        client: &GraphqlClient,
898        id: &str,
899    ) -> Result<(), WebhookError> {
900        let mutation = format!(
901            r#"
902            mutation {{
903                webhookSubscriptionDelete(id: "{id}") {{
904                    deletedWebhookSubscriptionId
905                    userErrors {{
906                        field
907                        message
908                    }}
909                }}
910            }}
911            "#,
912            id = id
913        );
914
915        let response = client.query(&mutation, None, None, None).await?;
916
917        // Check for userErrors
918        let user_errors = &response.body["data"]["webhookSubscriptionDelete"]["userErrors"];
919        if let Some(errors) = user_errors.as_array() {
920            if !errors.is_empty() {
921                let messages: Vec<String> = errors
922                    .iter()
923                    .filter_map(|e| e["message"].as_str().map(String::from))
924                    .collect();
925                return Err(WebhookError::ShopifyError {
926                    message: messages.join("; "),
927                });
928            }
929        }
930
931        Ok(())
932    }
933}
934
935/// Internal struct for holding existing webhook configuration from Shopify.
936#[derive(Debug, Clone)]
937struct ExistingWebhookConfig {
938    delivery_method: WebhookDeliveryMethod,
939    include_fields: Option<Vec<String>>,
940    metafield_namespaces: Option<Vec<String>>,
941    filter: Option<String>,
942}
943
944/// Builds the GraphQL input for the delivery method.
945///
946/// Uses the unified `uri` field which accepts:
947/// - HTTPS URLs for HTTP delivery
948/// - ARNs for EventBridge delivery
949/// - `pubsub://{project}:{topic}` URIs for Pub/Sub delivery
950fn build_delivery_input(delivery_method: &WebhookDeliveryMethod) -> String {
951    match delivery_method {
952        WebhookDeliveryMethod::Http { uri } => {
953            format!("uri: \"{}\"", uri)
954        }
955        WebhookDeliveryMethod::EventBridge { arn } => {
956            format!("uri: \"{}\"", arn)
957        }
958        WebhookDeliveryMethod::PubSub {
959            project_id,
960            topic_id,
961        } => {
962            format!("uri: \"pubsub://{}:{}\"", project_id, topic_id)
963        }
964    }
965}
966
967/// Converts a `WebhookTopic` to GraphQL enum format.
968///
969/// Transforms the serde format (e.g., "orders/create") to the GraphQL
970/// enum format (e.g., "ORDERS_CREATE").
971///
972/// # Example
973///
974/// ```rust,ignore
975/// use shopify_sdk::rest::resources::v2026_04::common::WebhookTopic;
976///
977/// let graphql_format = topic_to_graphql_format(&WebhookTopic::OrdersCreate);
978/// assert_eq!(graphql_format, "ORDERS_CREATE");
979/// ```
980fn topic_to_graphql_format(topic: &WebhookTopic) -> String {
981    // Serialize topic to get the serde format (e.g., "orders/create")
982    let json_str = serde_json::to_string(topic).unwrap_or_default();
983
984    // Remove quotes, replace "/" and "_" with "_", and uppercase
985    json_str.trim_matches('"').replace('/', "_").to_uppercase()
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::auth::oauth::hmac::compute_signature_base64;
992    use crate::config::{ApiKey, ApiSecretKey};
993    use crate::webhooks::types::BoxFuture;
994    use crate::WebhookRegistrationBuilder;
995    use std::sync::atomic::{AtomicBool, Ordering};
996    use std::sync::Arc;
997
998    // Test handler implementation
999    struct TestHandler {
1000        invoked: Arc<AtomicBool>,
1001    }
1002
1003    impl WebhookHandler for TestHandler {
1004        fn handle<'a>(
1005            &'a self,
1006            _context: super::super::verification::WebhookContext,
1007            _payload: serde_json::Value,
1008        ) -> BoxFuture<'a, Result<(), WebhookError>> {
1009            let invoked = self.invoked.clone();
1010            Box::pin(async move {
1011                invoked.store(true, Ordering::SeqCst);
1012                Ok(())
1013            })
1014        }
1015    }
1016
1017    // Error handler implementation for testing error propagation
1018    struct ErrorHandler {
1019        error_message: String,
1020    }
1021
1022    impl WebhookHandler for ErrorHandler {
1023        fn handle<'a>(
1024            &'a self,
1025            _context: super::super::verification::WebhookContext,
1026            _payload: serde_json::Value,
1027        ) -> BoxFuture<'a, Result<(), WebhookError>> {
1028            let message = self.error_message.clone();
1029            Box::pin(async move { Err(WebhookError::ShopifyError { message }) })
1030        }
1031    }
1032
1033    // ========================================================================
1034    // Task Group 4 Tests: ExistingWebhookConfig
1035    // ========================================================================
1036
1037    #[test]
1038    fn test_existing_config_with_http_delivery() {
1039        let config = ExistingWebhookConfig {
1040            delivery_method: WebhookDeliveryMethod::Http {
1041                uri: "https://example.com/webhooks".to_string(),
1042            },
1043            include_fields: Some(vec!["id".to_string()]),
1044            metafield_namespaces: None,
1045            filter: None,
1046        };
1047
1048        assert!(matches!(
1049            config.delivery_method,
1050            WebhookDeliveryMethod::Http { .. }
1051        ));
1052    }
1053
1054    #[test]
1055    fn test_existing_config_with_eventbridge_delivery() {
1056        let config = ExistingWebhookConfig {
1057            delivery_method: WebhookDeliveryMethod::EventBridge {
1058                arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1059            },
1060            include_fields: None,
1061            metafield_namespaces: None,
1062            filter: Some("status:active".to_string()),
1063        };
1064
1065        assert!(matches!(
1066            config.delivery_method,
1067            WebhookDeliveryMethod::EventBridge { .. }
1068        ));
1069        assert!(config.filter.is_some());
1070    }
1071
1072    #[test]
1073    fn test_existing_config_with_pubsub_delivery() {
1074        let config = ExistingWebhookConfig {
1075            delivery_method: WebhookDeliveryMethod::PubSub {
1076                project_id: "my-project".to_string(),
1077                topic_id: "my-topic".to_string(),
1078            },
1079            include_fields: None,
1080            metafield_namespaces: Some(vec!["custom".to_string()]),
1081            filter: None,
1082        };
1083
1084        match config.delivery_method {
1085            WebhookDeliveryMethod::PubSub {
1086                project_id,
1087                topic_id,
1088            } => {
1089                assert_eq!(project_id, "my-project");
1090                assert_eq!(topic_id, "my-topic");
1091            }
1092            _ => panic!("Expected PubSub delivery method"),
1093        }
1094    }
1095
1096    // ========================================================================
1097    // Task Group 5 Tests: GraphQL Query Parsing
1098    // ========================================================================
1099
1100    #[test]
1101    fn test_build_delivery_input_http() {
1102        let method = WebhookDeliveryMethod::Http {
1103            uri: "https://example.com/webhooks".to_string(),
1104        };
1105        let input = build_delivery_input(&method);
1106        // Uses unified uri field per Shopify API 2025-10+
1107        assert_eq!(input, "uri: \"https://example.com/webhooks\"");
1108    }
1109
1110    #[test]
1111    fn test_build_delivery_input_eventbridge() {
1112        let method = WebhookDeliveryMethod::EventBridge {
1113            arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1114        };
1115        let input = build_delivery_input(&method);
1116        // Uses unified uri field with ARN value
1117        assert_eq!(
1118            input,
1119            "uri: \"arn:aws:events:us-east-1::event-source/test\""
1120        );
1121    }
1122
1123    #[test]
1124    fn test_build_delivery_input_pubsub() {
1125        let method = WebhookDeliveryMethod::PubSub {
1126            project_id: "my-project".to_string(),
1127            topic_id: "my-topic".to_string(),
1128        };
1129        let input = build_delivery_input(&method);
1130        // Uses unified uri field with pubsub:// URI format
1131        assert_eq!(input, "uri: \"pubsub://my-project:my-topic\"");
1132    }
1133
1134    // ========================================================================
1135    // Task Group 6 Tests: config_matches()
1136    // ========================================================================
1137
1138    #[test]
1139    fn test_config_matches_http_same_url() {
1140        let registry = WebhookRegistry::new();
1141
1142        let existing = ExistingWebhookConfig {
1143            delivery_method: WebhookDeliveryMethod::Http {
1144                uri: "https://example.com/webhooks".to_string(),
1145            },
1146            include_fields: None,
1147            metafield_namespaces: None,
1148            filter: None,
1149        };
1150
1151        let registration = WebhookRegistrationBuilder::new(
1152            WebhookTopic::OrdersCreate,
1153            WebhookDeliveryMethod::Http {
1154                uri: "https://example.com/webhooks".to_string(),
1155            },
1156        )
1157        .build();
1158
1159        assert!(registry.config_matches(&existing, &registration));
1160    }
1161
1162    #[test]
1163    fn test_config_matches_http_different_url() {
1164        let registry = WebhookRegistry::new();
1165
1166        let existing = ExistingWebhookConfig {
1167            delivery_method: WebhookDeliveryMethod::Http {
1168                uri: "https://example.com/webhooks".to_string(),
1169            },
1170            include_fields: None,
1171            metafield_namespaces: None,
1172            filter: None,
1173        };
1174
1175        let registration = WebhookRegistrationBuilder::new(
1176            WebhookTopic::OrdersCreate,
1177            WebhookDeliveryMethod::Http {
1178                uri: "https://different.com/webhooks".to_string(),
1179            },
1180        )
1181        .build();
1182
1183        assert!(!registry.config_matches(&existing, &registration));
1184    }
1185
1186    #[test]
1187    fn test_config_matches_eventbridge_same_arn() {
1188        let registry = WebhookRegistry::new();
1189
1190        let existing = ExistingWebhookConfig {
1191            delivery_method: WebhookDeliveryMethod::EventBridge {
1192                arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1193            },
1194            include_fields: None,
1195            metafield_namespaces: None,
1196            filter: None,
1197        };
1198
1199        let registration = WebhookRegistrationBuilder::new(
1200            WebhookTopic::OrdersCreate,
1201            WebhookDeliveryMethod::EventBridge {
1202                arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1203            },
1204        )
1205        .build();
1206
1207        assert!(registry.config_matches(&existing, &registration));
1208    }
1209
1210    #[test]
1211    fn test_config_matches_pubsub_same_project_and_topic() {
1212        let registry = WebhookRegistry::new();
1213
1214        let existing = ExistingWebhookConfig {
1215            delivery_method: WebhookDeliveryMethod::PubSub {
1216                project_id: "my-project".to_string(),
1217                topic_id: "my-topic".to_string(),
1218            },
1219            include_fields: None,
1220            metafield_namespaces: None,
1221            filter: None,
1222        };
1223
1224        let registration = WebhookRegistrationBuilder::new(
1225            WebhookTopic::OrdersCreate,
1226            WebhookDeliveryMethod::PubSub {
1227                project_id: "my-project".to_string(),
1228                topic_id: "my-topic".to_string(),
1229            },
1230        )
1231        .build();
1232
1233        assert!(registry.config_matches(&existing, &registration));
1234    }
1235
1236    #[test]
1237    fn test_config_matches_different_delivery_methods_never_match() {
1238        let registry = WebhookRegistry::new();
1239
1240        let existing = ExistingWebhookConfig {
1241            delivery_method: WebhookDeliveryMethod::Http {
1242                uri: "https://example.com/webhooks".to_string(),
1243            },
1244            include_fields: None,
1245            metafield_namespaces: None,
1246            filter: None,
1247        };
1248
1249        let registration = WebhookRegistrationBuilder::new(
1250            WebhookTopic::OrdersCreate,
1251            WebhookDeliveryMethod::EventBridge {
1252                arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1253            },
1254        )
1255        .build();
1256
1257        assert!(!registry.config_matches(&existing, &registration));
1258    }
1259
1260    #[test]
1261    fn test_config_matches_includes_other_fields() {
1262        let registry = WebhookRegistry::new();
1263
1264        let existing = ExistingWebhookConfig {
1265            delivery_method: WebhookDeliveryMethod::Http {
1266                uri: "https://example.com/webhooks".to_string(),
1267            },
1268            include_fields: Some(vec!["id".to_string()]),
1269            metafield_namespaces: Some(vec!["custom".to_string()]),
1270            filter: Some("status:active".to_string()),
1271        };
1272
1273        let registration = WebhookRegistrationBuilder::new(
1274            WebhookTopic::OrdersCreate,
1275            WebhookDeliveryMethod::Http {
1276                uri: "https://example.com/webhooks".to_string(),
1277            },
1278        )
1279        .include_fields(vec!["id".to_string()])
1280        .metafield_namespaces(vec!["custom".to_string()])
1281        .filter("status:active".to_string())
1282        .build();
1283
1284        assert!(registry.config_matches(&existing, &registration));
1285
1286        // Different filter should not match
1287        let registration_different = WebhookRegistrationBuilder::new(
1288            WebhookTopic::OrdersCreate,
1289            WebhookDeliveryMethod::Http {
1290                uri: "https://example.com/webhooks".to_string(),
1291            },
1292        )
1293        .include_fields(vec!["id".to_string()])
1294        .metafield_namespaces(vec!["custom".to_string()])
1295        .filter("status:inactive".to_string())
1296        .build();
1297
1298        assert!(!registry.config_matches(&existing, &registration_different));
1299    }
1300
1301    // ========================================================================
1302    // Task Group 8 Tests: register() and register_all() behavior
1303    // ========================================================================
1304
1305    #[test]
1306    fn test_registry_accepts_http_delivery() {
1307        let mut registry = WebhookRegistry::new();
1308
1309        registry.add_registration(
1310            WebhookRegistrationBuilder::new(
1311                WebhookTopic::OrdersCreate,
1312                WebhookDeliveryMethod::Http {
1313                    uri: "https://example.com/webhooks".to_string(),
1314                },
1315            )
1316            .build(),
1317        );
1318
1319        let registration = registry
1320            .get_registration(&WebhookTopic::OrdersCreate)
1321            .unwrap();
1322        assert!(matches!(
1323            registration.delivery_method,
1324            WebhookDeliveryMethod::Http { .. }
1325        ));
1326    }
1327
1328    #[test]
1329    fn test_registry_accepts_eventbridge_delivery() {
1330        let mut registry = WebhookRegistry::new();
1331
1332        registry.add_registration(
1333            WebhookRegistrationBuilder::new(
1334                WebhookTopic::OrdersCreate,
1335                WebhookDeliveryMethod::EventBridge {
1336                    arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1337                },
1338            )
1339            .build(),
1340        );
1341
1342        let registration = registry
1343            .get_registration(&WebhookTopic::OrdersCreate)
1344            .unwrap();
1345        assert!(matches!(
1346            registration.delivery_method,
1347            WebhookDeliveryMethod::EventBridge { .. }
1348        ));
1349    }
1350
1351    #[test]
1352    fn test_registry_accepts_pubsub_delivery() {
1353        let mut registry = WebhookRegistry::new();
1354
1355        registry.add_registration(
1356            WebhookRegistrationBuilder::new(
1357                WebhookTopic::OrdersCreate,
1358                WebhookDeliveryMethod::PubSub {
1359                    project_id: "my-project".to_string(),
1360                    topic_id: "my-topic".to_string(),
1361                },
1362            )
1363            .build(),
1364        );
1365
1366        let registration = registry
1367            .get_registration(&WebhookTopic::OrdersCreate)
1368            .unwrap();
1369        assert!(matches!(
1370            registration.delivery_method,
1371            WebhookDeliveryMethod::PubSub { .. }
1372        ));
1373    }
1374
1375    #[test]
1376    fn test_registry_allows_mixed_delivery_methods() {
1377        let mut registry = WebhookRegistry::new();
1378
1379        registry
1380            .add_registration(
1381                WebhookRegistrationBuilder::new(
1382                    WebhookTopic::OrdersCreate,
1383                    WebhookDeliveryMethod::Http {
1384                        uri: "https://example.com/webhooks".to_string(),
1385                    },
1386                )
1387                .build(),
1388            )
1389            .add_registration(
1390                WebhookRegistrationBuilder::new(
1391                    WebhookTopic::ProductsUpdate,
1392                    WebhookDeliveryMethod::EventBridge {
1393                        arn: "arn:aws:events:us-east-1::event-source/test".to_string(),
1394                    },
1395                )
1396                .build(),
1397            )
1398            .add_registration(
1399                WebhookRegistrationBuilder::new(
1400                    WebhookTopic::CustomersCreate,
1401                    WebhookDeliveryMethod::PubSub {
1402                        project_id: "my-project".to_string(),
1403                        topic_id: "my-topic".to_string(),
1404                    },
1405                )
1406                .build(),
1407            );
1408
1409        assert_eq!(registry.list_registrations().len(), 3);
1410
1411        // Verify each registration has the correct delivery method type
1412        assert!(matches!(
1413            registry
1414                .get_registration(&WebhookTopic::OrdersCreate)
1415                .unwrap()
1416                .delivery_method,
1417            WebhookDeliveryMethod::Http { .. }
1418        ));
1419        assert!(matches!(
1420            registry
1421                .get_registration(&WebhookTopic::ProductsUpdate)
1422                .unwrap()
1423                .delivery_method,
1424            WebhookDeliveryMethod::EventBridge { .. }
1425        ));
1426        assert!(matches!(
1427            registry
1428                .get_registration(&WebhookTopic::CustomersCreate)
1429                .unwrap()
1430                .delivery_method,
1431            WebhookDeliveryMethod::PubSub { .. }
1432        ));
1433    }
1434
1435    // ========================================================================
1436    // Legacy Tests (updated for new API)
1437    // ========================================================================
1438
1439    #[test]
1440    fn test_webhook_registry_new_creates_empty_registry() {
1441        let registry = WebhookRegistry::new();
1442        assert!(registry.list_registrations().is_empty());
1443    }
1444
1445    #[test]
1446    fn test_add_registration_stores_registration() {
1447        let mut registry = WebhookRegistry::new();
1448
1449        registry.add_registration(
1450            WebhookRegistrationBuilder::new(
1451                WebhookTopic::OrdersCreate,
1452                WebhookDeliveryMethod::Http {
1453                    uri: "https://example.com/webhooks/orders".to_string(),
1454                },
1455            )
1456            .build(),
1457        );
1458
1459        assert_eq!(registry.list_registrations().len(), 1);
1460        assert!(registry
1461            .get_registration(&WebhookTopic::OrdersCreate)
1462            .is_some());
1463    }
1464
1465    #[test]
1466    fn test_add_registration_overwrites_same_topic() {
1467        let mut registry = WebhookRegistry::new();
1468
1469        // Add first registration
1470        registry.add_registration(
1471            WebhookRegistrationBuilder::new(
1472                WebhookTopic::OrdersCreate,
1473                WebhookDeliveryMethod::Http {
1474                    uri: "https://example.com/webhooks/v1/orders".to_string(),
1475                },
1476            )
1477            .build(),
1478        );
1479
1480        // Add second registration with same topic but different URL
1481        registry.add_registration(
1482            WebhookRegistrationBuilder::new(
1483                WebhookTopic::OrdersCreate,
1484                WebhookDeliveryMethod::Http {
1485                    uri: "https://example.com/webhooks/v2/orders".to_string(),
1486                },
1487            )
1488            .build(),
1489        );
1490
1491        assert_eq!(registry.list_registrations().len(), 1);
1492
1493        let registration = registry
1494            .get_registration(&WebhookTopic::OrdersCreate)
1495            .unwrap();
1496        match &registration.delivery_method {
1497            WebhookDeliveryMethod::Http { uri } => {
1498                assert_eq!(uri, "https://example.com/webhooks/v2/orders");
1499            }
1500            _ => panic!("Expected Http delivery method"),
1501        }
1502    }
1503
1504    #[test]
1505    fn test_get_registration_returns_none_for_missing_topic() {
1506        let registry = WebhookRegistry::new();
1507        assert!(registry
1508            .get_registration(&WebhookTopic::OrdersCreate)
1509            .is_none());
1510    }
1511
1512    #[test]
1513    fn test_list_registrations_returns_all() {
1514        let mut registry = WebhookRegistry::new();
1515
1516        registry
1517            .add_registration(
1518                WebhookRegistrationBuilder::new(
1519                    WebhookTopic::OrdersCreate,
1520                    WebhookDeliveryMethod::Http {
1521                        uri: "https://example.com/webhooks/orders".to_string(),
1522                    },
1523                )
1524                .build(),
1525            )
1526            .add_registration(
1527                WebhookRegistrationBuilder::new(
1528                    WebhookTopic::ProductsCreate,
1529                    WebhookDeliveryMethod::Http {
1530                        uri: "https://example.com/webhooks/products".to_string(),
1531                    },
1532                )
1533                .build(),
1534            )
1535            .add_registration(
1536                WebhookRegistrationBuilder::new(
1537                    WebhookTopic::CustomersCreate,
1538                    WebhookDeliveryMethod::Http {
1539                        uri: "https://example.com/webhooks/customers".to_string(),
1540                    },
1541                )
1542                .build(),
1543            );
1544
1545        let registrations = registry.list_registrations();
1546        assert_eq!(registrations.len(), 3);
1547    }
1548
1549    #[test]
1550    fn test_webhook_registry_is_send_sync() {
1551        fn assert_send_sync<T: Send + Sync>() {}
1552        assert_send_sync::<WebhookRegistry>();
1553    }
1554
1555    #[test]
1556    fn test_topic_to_graphql_format_orders_create() {
1557        let topic = WebhookTopic::OrdersCreate;
1558        let graphql_format = topic_to_graphql_format(&topic);
1559        assert_eq!(graphql_format, "ORDERS_CREATE");
1560    }
1561
1562    #[test]
1563    fn test_topic_to_graphql_format_products_update() {
1564        let topic = WebhookTopic::ProductsUpdate;
1565        let graphql_format = topic_to_graphql_format(&topic);
1566        assert_eq!(graphql_format, "PRODUCTS_UPDATE");
1567    }
1568
1569    #[test]
1570    fn test_topic_to_graphql_format_app_uninstalled() {
1571        let topic = WebhookTopic::AppUninstalled;
1572        let graphql_format = topic_to_graphql_format(&topic);
1573        assert_eq!(graphql_format, "APP_UNINSTALLED");
1574    }
1575
1576    #[test]
1577    fn test_topic_to_graphql_format_inventory_levels_update() {
1578        let topic = WebhookTopic::InventoryLevelsUpdate;
1579        let graphql_format = topic_to_graphql_format(&topic);
1580        assert_eq!(graphql_format, "INVENTORY_LEVELS_UPDATE");
1581    }
1582
1583    #[test]
1584    fn test_add_registration_returns_mut_self_for_chaining() {
1585        let mut registry = WebhookRegistry::new();
1586
1587        // Test method chaining
1588        let chain_result = registry
1589            .add_registration(
1590                WebhookRegistrationBuilder::new(
1591                    WebhookTopic::OrdersCreate,
1592                    WebhookDeliveryMethod::Http {
1593                        uri: "https://example.com/webhooks/orders".to_string(),
1594                    },
1595                )
1596                .build(),
1597            )
1598            .add_registration(
1599                WebhookRegistrationBuilder::new(
1600                    WebhookTopic::ProductsCreate,
1601                    WebhookDeliveryMethod::Http {
1602                        uri: "https://example.com/webhooks/products".to_string(),
1603                    },
1604                )
1605                .build(),
1606            );
1607
1608        // Verify chaining worked
1609        assert_eq!(chain_result.list_registrations().len(), 2);
1610    }
1611
1612    // ========================================================================
1613    // Handler Tests (updated for new API)
1614    // ========================================================================
1615
1616    #[test]
1617    fn test_add_registration_extracts_and_stores_handler_separately() {
1618        let invoked = Arc::new(AtomicBool::new(false));
1619        let handler = TestHandler {
1620            invoked: invoked.clone(),
1621        };
1622
1623        let mut registry = WebhookRegistry::new();
1624
1625        registry.add_registration(
1626            WebhookRegistrationBuilder::new(
1627                WebhookTopic::OrdersCreate,
1628                WebhookDeliveryMethod::Http {
1629                    uri: "https://example.com/webhooks/orders".to_string(),
1630                },
1631            )
1632            .handler(handler)
1633            .build(),
1634        );
1635
1636        // Verify registration exists
1637        assert!(registry
1638            .get_registration(&WebhookTopic::OrdersCreate)
1639            .is_some());
1640
1641        // Verify handler was stored separately in the handlers map
1642        assert!(registry.handlers.contains_key(&WebhookTopic::OrdersCreate));
1643    }
1644
1645    #[test]
1646    fn test_handler_lookup_by_topic_succeeds_for_registered_handler() {
1647        let invoked = Arc::new(AtomicBool::new(false));
1648        let handler = TestHandler {
1649            invoked: invoked.clone(),
1650        };
1651
1652        let mut registry = WebhookRegistry::new();
1653
1654        registry.add_registration(
1655            WebhookRegistrationBuilder::new(
1656                WebhookTopic::OrdersCreate,
1657                WebhookDeliveryMethod::Http {
1658                    uri: "https://example.com/webhooks/orders".to_string(),
1659                },
1660            )
1661            .handler(handler)
1662            .build(),
1663        );
1664
1665        // Lookup handler by topic
1666        let found_handler = registry.handlers.get(&WebhookTopic::OrdersCreate);
1667        assert!(found_handler.is_some());
1668    }
1669
1670    #[test]
1671    fn test_handler_lookup_returns_none_for_topic_without_handler() {
1672        let mut registry = WebhookRegistry::new();
1673
1674        // Add registration without handler
1675        registry.add_registration(
1676            WebhookRegistrationBuilder::new(
1677                WebhookTopic::OrdersCreate,
1678                WebhookDeliveryMethod::Http {
1679                    uri: "https://example.com/webhooks/orders".to_string(),
1680                },
1681            )
1682            .build(),
1683        );
1684
1685        // Lookup handler by topic
1686        let found_handler = registry.handlers.get(&WebhookTopic::OrdersCreate);
1687        assert!(found_handler.is_none());
1688    }
1689
1690    #[tokio::test]
1691    async fn test_process_returns_no_handler_for_topic_error() {
1692        let mut registry = WebhookRegistry::new();
1693
1694        // Add registration without handler
1695        registry.add_registration(
1696            WebhookRegistrationBuilder::new(
1697                WebhookTopic::OrdersCreate,
1698                WebhookDeliveryMethod::Http {
1699                    uri: "https://example.com/webhooks/orders".to_string(),
1700                },
1701            )
1702            .build(),
1703        );
1704
1705        let config = ShopifyConfig::builder()
1706            .api_key(ApiKey::new("key").unwrap())
1707            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1708            .build()
1709            .unwrap();
1710
1711        let body = b"{}";
1712        let hmac = compute_signature_base64(body, "secret");
1713        let request = WebhookRequest::new(
1714            body.to_vec(),
1715            hmac,
1716            Some("orders/create".to_string()),
1717            Some("shop.myshopify.com".to_string()),
1718            None,
1719            None,
1720        );
1721
1722        let result = registry.process(&config, &request).await;
1723        assert!(result.is_err());
1724
1725        match result.unwrap_err() {
1726            WebhookError::NoHandlerForTopic { topic } => {
1727                assert_eq!(topic, "orders/create");
1728            }
1729            other => panic!("Expected NoHandlerForTopic, got: {:?}", other),
1730        }
1731    }
1732
1733    #[tokio::test]
1734    async fn test_process_returns_payload_parse_error_for_invalid_json() {
1735        let invoked = Arc::new(AtomicBool::new(false));
1736        let handler = TestHandler {
1737            invoked: invoked.clone(),
1738        };
1739
1740        let mut registry = WebhookRegistry::new();
1741
1742        registry.add_registration(
1743            WebhookRegistrationBuilder::new(
1744                WebhookTopic::OrdersCreate,
1745                WebhookDeliveryMethod::Http {
1746                    uri: "https://example.com/webhooks/orders".to_string(),
1747                },
1748            )
1749            .handler(handler)
1750            .build(),
1751        );
1752
1753        let config = ShopifyConfig::builder()
1754            .api_key(ApiKey::new("key").unwrap())
1755            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1756            .build()
1757            .unwrap();
1758
1759        // Invalid JSON body
1760        let body = b"not valid json {{{";
1761        let hmac = compute_signature_base64(body, "secret");
1762        let request = WebhookRequest::new(
1763            body.to_vec(),
1764            hmac,
1765            Some("orders/create".to_string()),
1766            Some("shop.myshopify.com".to_string()),
1767            None,
1768            None,
1769        );
1770
1771        let result = registry.process(&config, &request).await;
1772        assert!(result.is_err());
1773
1774        match result.unwrap_err() {
1775            WebhookError::PayloadParseError { message } => {
1776                assert!(!message.is_empty());
1777            }
1778            other => panic!("Expected PayloadParseError, got: {:?}", other),
1779        }
1780
1781        // Ensure handler was not invoked
1782        assert!(!invoked.load(Ordering::SeqCst));
1783    }
1784
1785    #[tokio::test]
1786    async fn test_process_invokes_handler_with_correct_context_and_payload() {
1787        let invoked = Arc::new(AtomicBool::new(false));
1788        let handler = TestHandler {
1789            invoked: invoked.clone(),
1790        };
1791
1792        let mut registry = WebhookRegistry::new();
1793
1794        registry.add_registration(
1795            WebhookRegistrationBuilder::new(
1796                WebhookTopic::OrdersCreate,
1797                WebhookDeliveryMethod::Http {
1798                    uri: "https://example.com/webhooks/orders".to_string(),
1799                },
1800            )
1801            .handler(handler)
1802            .build(),
1803        );
1804
1805        let config = ShopifyConfig::builder()
1806            .api_key(ApiKey::new("key").unwrap())
1807            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1808            .build()
1809            .unwrap();
1810
1811        let body = br#"{"order_id": 123}"#;
1812        let hmac = compute_signature_base64(body, "secret");
1813        let request = WebhookRequest::new(
1814            body.to_vec(),
1815            hmac,
1816            Some("orders/create".to_string()),
1817            Some("shop.myshopify.com".to_string()),
1818            None,
1819            None,
1820        );
1821
1822        let result = registry.process(&config, &request).await;
1823        assert!(result.is_ok());
1824
1825        // Verify handler was invoked
1826        assert!(invoked.load(Ordering::SeqCst));
1827    }
1828
1829    #[tokio::test]
1830    async fn test_handler_error_propagation_through_process() {
1831        let handler = ErrorHandler {
1832            error_message: "Handler failed intentionally".to_string(),
1833        };
1834
1835        let mut registry = WebhookRegistry::new();
1836
1837        registry.add_registration(
1838            WebhookRegistrationBuilder::new(
1839                WebhookTopic::OrdersCreate,
1840                WebhookDeliveryMethod::Http {
1841                    uri: "https://example.com/webhooks/orders".to_string(),
1842                },
1843            )
1844            .handler(handler)
1845            .build(),
1846        );
1847
1848        let config = ShopifyConfig::builder()
1849            .api_key(ApiKey::new("key").unwrap())
1850            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1851            .build()
1852            .unwrap();
1853
1854        let body = br#"{"order_id": 123}"#;
1855        let hmac = compute_signature_base64(body, "secret");
1856        let request = WebhookRequest::new(
1857            body.to_vec(),
1858            hmac,
1859            Some("orders/create".to_string()),
1860            Some("shop.myshopify.com".to_string()),
1861            None,
1862            None,
1863        );
1864
1865        let result = registry.process(&config, &request).await;
1866        assert!(result.is_err());
1867
1868        match result.unwrap_err() {
1869            WebhookError::ShopifyError { message } => {
1870                assert_eq!(message, "Handler failed intentionally");
1871            }
1872            other => panic!("Expected ShopifyError, got: {:?}", other),
1873        }
1874    }
1875
1876    #[tokio::test]
1877    async fn test_multiple_handlers_for_different_topics() {
1878        let orders_invoked = Arc::new(AtomicBool::new(false));
1879        let products_invoked = Arc::new(AtomicBool::new(false));
1880
1881        let orders_handler = TestHandler {
1882            invoked: orders_invoked.clone(),
1883        };
1884        let products_handler = TestHandler {
1885            invoked: products_invoked.clone(),
1886        };
1887
1888        let mut registry = WebhookRegistry::new();
1889
1890        registry
1891            .add_registration(
1892                WebhookRegistrationBuilder::new(
1893                    WebhookTopic::OrdersCreate,
1894                    WebhookDeliveryMethod::Http {
1895                        uri: "https://example.com/webhooks/orders".to_string(),
1896                    },
1897                )
1898                .handler(orders_handler)
1899                .build(),
1900            )
1901            .add_registration(
1902                WebhookRegistrationBuilder::new(
1903                    WebhookTopic::ProductsCreate,
1904                    WebhookDeliveryMethod::Http {
1905                        uri: "https://example.com/webhooks/products".to_string(),
1906                    },
1907                )
1908                .handler(products_handler)
1909                .build(),
1910            );
1911
1912        let config = ShopifyConfig::builder()
1913            .api_key(ApiKey::new("key").unwrap())
1914            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1915            .build()
1916            .unwrap();
1917
1918        // Process orders webhook
1919        let orders_body = br#"{"order_id": 123}"#;
1920        let orders_hmac = compute_signature_base64(orders_body, "secret");
1921        let orders_request = WebhookRequest::new(
1922            orders_body.to_vec(),
1923            orders_hmac,
1924            Some("orders/create".to_string()),
1925            Some("shop.myshopify.com".to_string()),
1926            None,
1927            None,
1928        );
1929
1930        let result = registry.process(&config, &orders_request).await;
1931        assert!(result.is_ok());
1932        assert!(orders_invoked.load(Ordering::SeqCst));
1933        assert!(!products_invoked.load(Ordering::SeqCst));
1934
1935        // Process products webhook
1936        let products_body = br#"{"product_id": 456}"#;
1937        let products_hmac = compute_signature_base64(products_body, "secret");
1938        let products_request = WebhookRequest::new(
1939            products_body.to_vec(),
1940            products_hmac,
1941            Some("products/create".to_string()),
1942            Some("shop.myshopify.com".to_string()),
1943            None,
1944            None,
1945        );
1946
1947        let result = registry.process(&config, &products_request).await;
1948        assert!(result.is_ok());
1949        assert!(products_invoked.load(Ordering::SeqCst));
1950    }
1951
1952    #[tokio::test]
1953    async fn test_handler_replacement_when_re_registering_same_topic() {
1954        let first_invoked = Arc::new(AtomicBool::new(false));
1955        let second_invoked = Arc::new(AtomicBool::new(false));
1956
1957        let first_handler = TestHandler {
1958            invoked: first_invoked.clone(),
1959        };
1960        let second_handler = TestHandler {
1961            invoked: second_invoked.clone(),
1962        };
1963
1964        let mut registry = WebhookRegistry::new();
1965
1966        // Register first handler
1967        registry.add_registration(
1968            WebhookRegistrationBuilder::new(
1969                WebhookTopic::OrdersCreate,
1970                WebhookDeliveryMethod::Http {
1971                    uri: "https://example.com/webhooks/orders".to_string(),
1972                },
1973            )
1974            .handler(first_handler)
1975            .build(),
1976        );
1977
1978        // Replace with second handler
1979        registry.add_registration(
1980            WebhookRegistrationBuilder::new(
1981                WebhookTopic::OrdersCreate,
1982                WebhookDeliveryMethod::Http {
1983                    uri: "https://example.com/webhooks/orders/v2".to_string(),
1984                },
1985            )
1986            .handler(second_handler)
1987            .build(),
1988        );
1989
1990        let config = ShopifyConfig::builder()
1991            .api_key(ApiKey::new("key").unwrap())
1992            .api_secret_key(ApiSecretKey::new("secret").unwrap())
1993            .build()
1994            .unwrap();
1995
1996        let body = br#"{"order_id": 123}"#;
1997        let hmac = compute_signature_base64(body, "secret");
1998        let request = WebhookRequest::new(
1999            body.to_vec(),
2000            hmac,
2001            Some("orders/create".to_string()),
2002            Some("shop.myshopify.com".to_string()),
2003            None,
2004            None,
2005        );
2006
2007        let result = registry.process(&config, &request).await;
2008        assert!(result.is_ok());
2009
2010        // Only second handler should be invoked
2011        assert!(!first_invoked.load(Ordering::SeqCst));
2012        assert!(second_invoked.load(Ordering::SeqCst));
2013    }
2014
2015    #[tokio::test]
2016    async fn test_process_returns_invalid_hmac_for_bad_signature() {
2017        let invoked = Arc::new(AtomicBool::new(false));
2018        let handler = TestHandler {
2019            invoked: invoked.clone(),
2020        };
2021
2022        let mut registry = WebhookRegistry::new();
2023
2024        registry.add_registration(
2025            WebhookRegistrationBuilder::new(
2026                WebhookTopic::OrdersCreate,
2027                WebhookDeliveryMethod::Http {
2028                    uri: "https://example.com/webhooks/orders".to_string(),
2029                },
2030            )
2031            .handler(handler)
2032            .build(),
2033        );
2034
2035        let config = ShopifyConfig::builder()
2036            .api_key(ApiKey::new("key").unwrap())
2037            .api_secret_key(ApiSecretKey::new("secret").unwrap())
2038            .build()
2039            .unwrap();
2040
2041        let body = br#"{"order_id": 123}"#;
2042        // Use wrong secret for HMAC
2043        let hmac = compute_signature_base64(body, "wrong-secret");
2044        let request = WebhookRequest::new(
2045            body.to_vec(),
2046            hmac,
2047            Some("orders/create".to_string()),
2048            Some("shop.myshopify.com".to_string()),
2049            None,
2050            None,
2051        );
2052
2053        let result = registry.process(&config, &request).await;
2054        assert!(result.is_err());
2055        assert!(matches!(result.unwrap_err(), WebhookError::InvalidHmac));
2056
2057        // Handler should not be invoked
2058        assert!(!invoked.load(Ordering::SeqCst));
2059    }
2060
2061    #[tokio::test]
2062    async fn test_process_handles_unknown_topic() {
2063        let invoked = Arc::new(AtomicBool::new(false));
2064        let handler = TestHandler {
2065            invoked: invoked.clone(),
2066        };
2067
2068        let mut registry = WebhookRegistry::new();
2069
2070        registry.add_registration(
2071            WebhookRegistrationBuilder::new(
2072                WebhookTopic::OrdersCreate,
2073                WebhookDeliveryMethod::Http {
2074                    uri: "https://example.com/webhooks/orders".to_string(),
2075                },
2076            )
2077            .handler(handler)
2078            .build(),
2079        );
2080
2081        let config = ShopifyConfig::builder()
2082            .api_key(ApiKey::new("key").unwrap())
2083            .api_secret_key(ApiSecretKey::new("secret").unwrap())
2084            .build()
2085            .unwrap();
2086
2087        let body = br#"{"data": "test"}"#;
2088        let hmac = compute_signature_base64(body, "secret");
2089        let request = WebhookRequest::new(
2090            body.to_vec(),
2091            hmac,
2092            Some("custom/unknown_topic".to_string()),
2093            Some("shop.myshopify.com".to_string()),
2094            None,
2095            None,
2096        );
2097
2098        let result = registry.process(&config, &request).await;
2099        assert!(result.is_err());
2100
2101        match result.unwrap_err() {
2102            WebhookError::NoHandlerForTopic { topic } => {
2103                assert_eq!(topic, "custom/unknown_topic");
2104            }
2105            other => panic!("Expected NoHandlerForTopic, got: {:?}", other),
2106        }
2107
2108        // Handler should not be invoked
2109        assert!(!invoked.load(Ordering::SeqCst));
2110    }
2111}