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