1use 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#[derive(Default)]
113pub struct WebhookRegistry {
114 registrations: HashMap<WebhookTopic, WebhookRegistration>,
116 handlers: HashMap<WebhookTopic, Box<dyn WebhookHandler>>,
118}
119
120impl 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
130const _: fn() = || {
132 const fn assert_send_sync<T: Send + Sync>() {}
133 assert_send_sync::<WebhookRegistry>();
134};
135
136impl WebhookRegistry {
137 #[must_use]
148 pub fn new() -> Self {
149 Self {
150 registrations: HashMap::new(),
151 handlers: HashMap::new(),
152 }
153 }
154
155 pub fn add_registration(&mut self, mut registration: WebhookRegistration) -> &mut Self {
198 let topic = registration.topic;
199
200 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 #[must_use]
241 pub fn get_registration(&self, topic: &WebhookTopic) -> Option<&WebhookRegistration> {
242 self.registrations.get(topic)
243 }
244
245 #[must_use]
281 pub fn list_registrations(&self) -> Vec<&WebhookRegistration> {
282 self.registrations.values().collect()
283 }
284
285 pub async fn process(
321 &self,
322 config: &ShopifyConfig,
323 request: &WebhookRequest,
324 ) -> Result<(), WebhookError> {
325 let context = verify_webhook(config, request)?;
327
328 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 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 handler.handle(context, payload).await
346 }
347
348 pub async fn register(
389 &self,
390 session: &Session,
391 config: &ShopifyConfig,
392 topic: &WebhookTopic,
393 ) -> Result<WebhookRegistrationResult, WebhookError> {
394 let registration = self
396 .get_registration(topic)
397 .ok_or_else(|| WebhookError::RegistrationNotFound {
398 topic: topic.clone(),
399 })?;
400
401 let graphql_topic = topic_to_graphql_format(topic);
403
404 let client = GraphqlClient::new(session, Some(config));
406
407 let existing = self
409 .query_existing_subscription(&client, &graphql_topic, ®istration.delivery_method)
410 .await?;
411
412 match existing {
413 Some((id, existing_config)) => {
414 if self.config_matches(&existing_config, registration) {
416 Ok(WebhookRegistrationResult::AlreadyRegistered { id })
417 } else {
418 self.update_subscription(&client, &id, registration).await
420 }
421 }
422 None => {
423 self.create_subscription(&client, &graphql_topic, registration)
425 .await
426 }
427 }
428 }
429
430 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, ®istration.topic).await {
472 Ok(result) => result,
473 Err(error) => WebhookRegistrationResult::Failed(error),
474 };
475 results.push(result);
476 }
477
478 results
479 }
480
481 pub async fn unregister(
507 &self,
508 session: &Session,
509 config: &ShopifyConfig,
510 topic: &WebhookTopic,
511 ) -> Result<(), WebhookError> {
512 let registration = self
514 .get_registration(topic)
515 .ok_or_else(|| WebhookError::RegistrationNotFound {
516 topic: topic.clone(),
517 })?;
518
519 let graphql_topic = topic_to_graphql_format(topic);
521
522 let client = GraphqlClient::new(session, Some(config));
524
525 let existing = self
527 .query_existing_subscription(&client, &graphql_topic, ®istration.delivery_method)
528 .await?;
529
530 match existing {
531 Some((id, _)) => {
532 self.delete_subscription(&client, &id).await
534 }
535 None => Err(WebhookError::SubscriptionNotFound {
536 topic: topic.clone(),
537 }),
538 }
539 }
540
541 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, ®istration.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 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 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 for edge in edges {
638 let node = &edge["node"];
639 let endpoint = &node["endpoint"];
640
641 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 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 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 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(®istration.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 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 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 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(®istration.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 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 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 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#[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
943fn 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
966fn topic_to_graphql_format(topic: &WebhookTopic) -> String {
980 let json_str = serde_json::to_string(topic).unwrap_or_default();
982
983 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 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 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 #[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 #[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 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 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 assert_eq!(input, "uri: \"pubsub://my-project:my-topic\"");
1133 }
1134
1135 #[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, ®istration));
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, ®istration));
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, ®istration));
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, ®istration));
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, ®istration));
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, ®istration));
1286
1287 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, ®istration_different));
1300 }
1301
1302 #[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 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 #[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 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 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 ®istration.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 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 assert_eq!(chain_result.list_registrations().len(), 2);
1599 }
1600
1601 #[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 assert!(registry.get_registration(&WebhookTopic::OrdersCreate).is_some());
1627
1628 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!invoked.load(Ordering::SeqCst));
2097 }
2098}