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