1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use std::fmt;
20
21#[derive(Clone, Debug, PartialEq)]
36#[non_exhaustive]
37pub enum WebhookEvent {
38 BatchSucceeded,
40 BatchExpired,
42 BatchFailed,
44 InteractionRequiresAction,
46 InteractionCompleted,
48 InteractionFailed,
50 VideoGenerated,
52 Unknown {
54 event_type: String,
56 data: serde_json::Value,
58 },
59}
60
61impl WebhookEvent {
62 #[must_use]
64 pub const fn is_unknown(&self) -> bool {
65 matches!(self, Self::Unknown { .. })
66 }
67
68 #[must_use]
70 pub fn unknown_event_type(&self) -> Option<&str> {
71 match self {
72 Self::Unknown { event_type, .. } => Some(event_type),
73 _ => None,
74 }
75 }
76
77 #[must_use]
79 pub fn unknown_data(&self) -> Option<&serde_json::Value> {
80 match self {
81 Self::Unknown { data, .. } => Some(data),
82 _ => None,
83 }
84 }
85
86 const fn as_wire(&self) -> Option<&'static str> {
87 match self {
88 Self::BatchSucceeded => Some("batch.succeeded"),
89 Self::BatchExpired => Some("batch.expired"),
90 Self::BatchFailed => Some("batch.failed"),
91 Self::InteractionRequiresAction => Some("interaction.requires_action"),
92 Self::InteractionCompleted => Some("interaction.completed"),
93 Self::InteractionFailed => Some("interaction.failed"),
94 Self::VideoGenerated => Some("video.generated"),
95 Self::Unknown { .. } => None,
96 }
97 }
98}
99
100impl fmt::Display for WebhookEvent {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self.as_wire() {
103 Some(wire) => write!(f, "{}", wire),
104 None => match self {
105 Self::Unknown { event_type, .. } => write!(f, "{}", event_type),
106 _ => unreachable!("known events always have a wire form"),
107 },
108 }
109 }
110}
111
112impl Serialize for WebhookEvent {
113 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114 where
115 S: Serializer,
116 {
117 match self.as_wire() {
118 Some(wire) => serializer.serialize_str(wire),
119 None => match self {
120 Self::Unknown { event_type, .. } => serializer.serialize_str(event_type),
121 _ => unreachable!("known events always have a wire form"),
122 },
123 }
124 }
125}
126
127impl<'de> Deserialize<'de> for WebhookEvent {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 let value = serde_json::Value::deserialize(deserializer)?;
133 match value.as_str() {
134 Some("batch.succeeded") => Ok(Self::BatchSucceeded),
135 Some("batch.expired") => Ok(Self::BatchExpired),
136 Some("batch.failed") => Ok(Self::BatchFailed),
137 Some("interaction.requires_action") => Ok(Self::InteractionRequiresAction),
138 Some("interaction.completed") => Ok(Self::InteractionCompleted),
139 Some("interaction.failed") => Ok(Self::InteractionFailed),
140 Some("video.generated") => Ok(Self::VideoGenerated),
141 Some(other) => {
142 tracing::warn!(
143 "Encountered unknown WebhookEvent '{}' - using Unknown variant (Evergreen)",
144 other
145 );
146 Ok(Self::Unknown {
147 event_type: other.to_string(),
148 data: value,
149 })
150 }
151 None => {
152 let event_type = format!("<non-string: {}>", value);
153 tracing::warn!(
154 "WebhookEvent received non-string value: {}. \
155 Preserving in Unknown variant.",
156 value
157 );
158 Ok(Self::Unknown {
159 event_type,
160 data: value,
161 })
162 }
163 }
164 }
165}
166
167#[derive(Clone, Debug, PartialEq)]
181#[non_exhaustive]
182pub enum WebhookState {
183 Enabled,
185 Disabled,
187 DisabledDueToFailedDeliveries,
189 Unknown {
191 state_type: String,
193 data: serde_json::Value,
195 },
196}
197
198impl WebhookState {
199 #[must_use]
201 pub const fn is_unknown(&self) -> bool {
202 matches!(self, Self::Unknown { .. })
203 }
204
205 #[must_use]
207 pub fn unknown_state_type(&self) -> Option<&str> {
208 match self {
209 Self::Unknown { state_type, .. } => Some(state_type),
210 _ => None,
211 }
212 }
213
214 #[must_use]
216 pub fn unknown_data(&self) -> Option<&serde_json::Value> {
217 match self {
218 Self::Unknown { data, .. } => Some(data),
219 _ => None,
220 }
221 }
222}
223
224impl Serialize for WebhookState {
225 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
226 where
227 S: Serializer,
228 {
229 match self {
230 Self::Enabled => serializer.serialize_str("enabled"),
231 Self::Disabled => serializer.serialize_str("disabled"),
232 Self::DisabledDueToFailedDeliveries => {
233 serializer.serialize_str("disabled_due_to_failed_deliveries")
234 }
235 Self::Unknown { state_type, .. } => serializer.serialize_str(state_type),
236 }
237 }
238}
239
240impl<'de> Deserialize<'de> for WebhookState {
241 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242 where
243 D: Deserializer<'de>,
244 {
245 let value = serde_json::Value::deserialize(deserializer)?;
246 match value.as_str() {
247 Some("enabled") => Ok(Self::Enabled),
248 Some("disabled") => Ok(Self::Disabled),
249 Some("disabled_due_to_failed_deliveries") => Ok(Self::DisabledDueToFailedDeliveries),
250 Some(other) => {
251 tracing::warn!(
252 "Encountered unknown WebhookState '{}' - using Unknown variant (Evergreen)",
253 other
254 );
255 Ok(Self::Unknown {
256 state_type: other.to_string(),
257 data: value,
258 })
259 }
260 None => {
261 let state_type = format!("<non-string: {}>", value);
262 tracing::warn!(
263 "WebhookState received non-string value: {}. \
264 Preserving in Unknown variant.",
265 value
266 );
267 Ok(Self::Unknown {
268 state_type,
269 data: value,
270 })
271 }
272 }
273 }
274}
275
276#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
278#[serde(default)]
279pub struct SigningSecret {
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub truncated_secret: Option<String>,
283 #[serde(
286 default,
287 skip_serializing_if = "Option::is_none",
288 deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForSigningSecret>"
289 )]
290 pub expire_time: Option<DateTime<Utc>>,
291}
292
293#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
311#[serde(default)]
312pub struct Webhook {
313 pub uri: String,
315 pub subscribed_events: Vec<WebhookEvent>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub name: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub id: Option<String>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub state: Option<WebhookState>,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub signing_secrets: Option<Vec<SigningSecret>>,
329 #[serde(skip_serializing_if = "Option::is_none")]
332 pub new_signing_secret: Option<String>,
333 #[serde(
337 default,
338 skip_serializing_if = "Option::is_none",
339 deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForWebhook>"
340 )]
341 pub create_time: Option<DateTime<Utc>>,
342 #[serde(
346 default,
347 skip_serializing_if = "Option::is_none",
348 deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForWebhook>"
349 )]
350 pub update_time: Option<DateTime<Utc>>,
351}
352
353impl std::fmt::Debug for Webhook {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 f.debug_struct("Webhook")
358 .field("uri", &self.uri)
359 .field("subscribed_events", &self.subscribed_events)
360 .field("name", &self.name)
361 .field("id", &self.id)
362 .field("state", &self.state)
363 .field("signing_secrets", &self.signing_secrets)
364 .field(
365 "new_signing_secret",
366 &self.new_signing_secret.as_ref().map(|_| "[REDACTED]"),
367 )
368 .field("create_time", &self.create_time)
369 .field("update_time", &self.update_time)
370 .finish()
371 }
372}
373
374impl Webhook {
375 #[must_use]
377 pub fn new(uri: impl Into<String>, subscribed_events: Vec<WebhookEvent>) -> Self {
378 Self {
379 uri: uri.into(),
380 subscribed_events,
381 ..Default::default()
382 }
383 }
384
385 #[must_use]
387 pub fn with_name(mut self, name: impl Into<String>) -> Self {
388 self.name = Some(name.into());
389 self
390 }
391}
392
393#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
399#[serde(default)]
400pub struct WebhookUpdate {
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub name: Option<String>,
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub uri: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub subscribed_events: Option<Vec<WebhookEvent>>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub state: Option<WebhookState>,
413}
414
415impl WebhookUpdate {
416 #[must_use]
418 pub fn new() -> Self {
419 Self::default()
420 }
421
422 #[must_use]
424 pub fn with_name(mut self, name: impl Into<String>) -> Self {
425 self.name = Some(name.into());
426 self
427 }
428
429 #[must_use]
431 pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
432 self.uri = Some(uri.into());
433 self
434 }
435
436 #[must_use]
438 pub fn with_subscribed_events(mut self, events: Vec<WebhookEvent>) -> Self {
439 self.subscribed_events = Some(events);
440 self
441 }
442
443 #[must_use]
445 pub fn with_state(mut self, state: WebhookState) -> Self {
446 self.state = Some(state);
447 self
448 }
449}
450
451#[derive(Clone, Debug, PartialEq)]
460#[non_exhaustive]
461pub enum RevocationBehavior {
462 RevokePreviousSecretsAfterH24,
464 RevokePreviousSecretsImmediately,
466 Unknown {
468 behavior_type: String,
470 data: serde_json::Value,
472 },
473}
474
475impl RevocationBehavior {
476 #[must_use]
478 pub const fn is_unknown(&self) -> bool {
479 matches!(self, Self::Unknown { .. })
480 }
481
482 #[must_use]
484 pub fn unknown_behavior_type(&self) -> Option<&str> {
485 match self {
486 Self::Unknown { behavior_type, .. } => Some(behavior_type),
487 _ => None,
488 }
489 }
490
491 #[must_use]
493 pub fn unknown_data(&self) -> Option<&serde_json::Value> {
494 match self {
495 Self::Unknown { data, .. } => Some(data),
496 _ => None,
497 }
498 }
499}
500
501impl Serialize for RevocationBehavior {
502 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
503 where
504 S: Serializer,
505 {
506 match self {
507 Self::RevokePreviousSecretsAfterH24 => {
508 serializer.serialize_str("revoke_previous_secrets_after_h24")
509 }
510 Self::RevokePreviousSecretsImmediately => {
511 serializer.serialize_str("revoke_previous_secrets_immediately")
512 }
513 Self::Unknown { behavior_type, .. } => serializer.serialize_str(behavior_type),
514 }
515 }
516}
517
518impl<'de> Deserialize<'de> for RevocationBehavior {
519 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
520 where
521 D: Deserializer<'de>,
522 {
523 let value = serde_json::Value::deserialize(deserializer)?;
524 match value.as_str() {
525 Some("revoke_previous_secrets_after_h24") => Ok(Self::RevokePreviousSecretsAfterH24),
526 Some("revoke_previous_secrets_immediately") => {
527 Ok(Self::RevokePreviousSecretsImmediately)
528 }
529 Some(other) => {
530 tracing::warn!(
531 "Encountered unknown RevocationBehavior '{}' - using Unknown variant (Evergreen)",
532 other
533 );
534 Ok(Self::Unknown {
535 behavior_type: other.to_string(),
536 data: value,
537 })
538 }
539 None => {
540 let behavior_type = format!("<non-string: {}>", value);
541 tracing::warn!(
542 "RevocationBehavior received non-string value: {}. \
543 Preserving in Unknown variant.",
544 value
545 );
546 Ok(Self::Unknown {
547 behavior_type,
548 data: value,
549 })
550 }
551 }
552 }
553}
554
555#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
557#[serde(default)]
558pub struct WebhookListResponse {
559 #[serde(deserialize_with = "crate::serde_util::deserialize_lenient_vec")]
562 pub webhooks: Vec<Webhook>,
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub next_page_token: Option<String>,
566}
567
568#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
570#[serde(default)]
571pub struct RotateSigningSecretResponse {
572 #[serde(skip_serializing_if = "Option::is_none")]
575 pub secret: Option<String>,
576}
577
578impl std::fmt::Debug for RotateSigningSecretResponse {
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 f.debug_struct("RotateSigningSecretResponse")
583 .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
584 .finish()
585 }
586}
587
588#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
604#[serde(default)]
605pub struct WebhookConfig {
606 #[serde(skip_serializing_if = "Option::is_none")]
609 pub uris: Option<Vec<String>>,
610 #[serde(skip_serializing_if = "Option::is_none")]
612 pub user_metadata: Option<serde_json::Value>,
613}
614
615impl WebhookConfig {
616 #[must_use]
618 pub fn new() -> Self {
619 Self::default()
620 }
621
622 #[must_use]
624 pub fn with_uris(mut self, uris: Vec<String>) -> Self {
625 self.uris = Some(uris);
626 self
627 }
628
629 #[must_use]
631 pub fn with_user_metadata(mut self, metadata: serde_json::Value) -> Self {
632 self.user_metadata = Some(metadata);
633 self
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use serde_json::json;
641
642 #[test]
643 fn test_webhook_event_wire_roundtrip() {
644 for (event, wire) in [
645 (WebhookEvent::BatchSucceeded, "\"batch.succeeded\""),
646 (WebhookEvent::BatchExpired, "\"batch.expired\""),
647 (WebhookEvent::BatchFailed, "\"batch.failed\""),
648 (
649 WebhookEvent::InteractionRequiresAction,
650 "\"interaction.requires_action\"",
651 ),
652 (
653 WebhookEvent::InteractionCompleted,
654 "\"interaction.completed\"",
655 ),
656 (WebhookEvent::InteractionFailed, "\"interaction.failed\""),
657 (WebhookEvent::VideoGenerated, "\"video.generated\""),
658 ] {
659 assert_eq!(serde_json::to_string(&event).unwrap(), wire);
660 let parsed: WebhookEvent = serde_json::from_str(wire).unwrap();
661 assert_eq!(parsed, event);
662 }
663 }
664
665 #[test]
666 fn test_webhook_event_unknown_roundtrip() {
667 let unknown: WebhookEvent = serde_json::from_str("\"file.generated\"").unwrap();
668 assert!(unknown.is_unknown());
669 assert_eq!(unknown.unknown_event_type(), Some("file.generated"));
670 assert!(unknown.unknown_data().is_some());
671 assert_eq!(
672 serde_json::to_string(&unknown).unwrap(),
673 "\"file.generated\""
674 );
675 }
676
677 #[test]
678 fn test_webhook_state_wire_roundtrip() {
679 for (state, wire) in [
680 (WebhookState::Enabled, "\"enabled\""),
681 (WebhookState::Disabled, "\"disabled\""),
682 (
683 WebhookState::DisabledDueToFailedDeliveries,
684 "\"disabled_due_to_failed_deliveries\"",
685 ),
686 ] {
687 assert_eq!(serde_json::to_string(&state).unwrap(), wire);
688 let parsed: WebhookState = serde_json::from_str(wire).unwrap();
689 assert_eq!(parsed, state);
690 }
691 }
692
693 #[test]
694 fn test_webhook_state_unknown_roundtrip() {
695 let unknown: WebhookState = serde_json::from_str("\"paused\"").unwrap();
696 assert!(unknown.is_unknown());
697 assert_eq!(unknown.unknown_state_type(), Some("paused"));
698 assert!(unknown.unknown_data().is_some());
699 assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"paused\"");
700 }
701
702 #[test]
703 fn test_revocation_behavior_wire_roundtrip() {
704 for (behavior, wire) in [
705 (
706 RevocationBehavior::RevokePreviousSecretsAfterH24,
707 "\"revoke_previous_secrets_after_h24\"",
708 ),
709 (
710 RevocationBehavior::RevokePreviousSecretsImmediately,
711 "\"revoke_previous_secrets_immediately\"",
712 ),
713 ] {
714 assert_eq!(serde_json::to_string(&behavior).unwrap(), wire);
715 let parsed: RevocationBehavior = serde_json::from_str(wire).unwrap();
716 assert_eq!(parsed, behavior);
717 }
718 }
719
720 #[test]
721 fn test_revocation_behavior_unknown_roundtrip() {
722 let unknown: RevocationBehavior = serde_json::from_str("\"revoke_after_week\"").unwrap();
723 assert!(unknown.is_unknown());
724 assert_eq!(unknown.unknown_behavior_type(), Some("revoke_after_week"));
725 assert!(unknown.unknown_data().is_some());
726 assert_eq!(
727 serde_json::to_string(&unknown).unwrap(),
728 "\"revoke_after_week\""
729 );
730 }
731
732 #[test]
733 fn test_webhook_new_serializes_input_fields_only() {
734 let webhook = Webhook::new(
735 "https://example.com/hook",
736 vec![WebhookEvent::InteractionCompleted],
737 )
738 .with_name("my-hook");
739
740 let value = serde_json::to_value(&webhook).unwrap();
741 assert_eq!(value["uri"], "https://example.com/hook");
742 assert_eq!(value["subscribed_events"][0], "interaction.completed");
743 assert_eq!(value["name"], "my-hook");
744 for field in [
746 "id",
747 "state",
748 "signing_secrets",
749 "new_signing_secret",
750 "create_time",
751 "update_time",
752 ] {
753 assert!(value.get(field).is_none(), "{field} should be skipped");
754 }
755 }
756
757 #[test]
758 fn test_webhook_full_resource_roundtrip() {
759 let json = json!({
761 "id": "wh123bare0pq",
762 "name": "my-hook",
763 "uri": "https://example.com/hook",
764 "subscribed_events": ["batch.succeeded", "interaction.failed", "video.generated"],
765 "state": "enabled",
766 "signing_secrets": [
767 {"truncated_secret": "whsec_...abcd", "expire_time": "2026-08-01T00:00:00Z"}
768 ],
769 "new_signing_secret": "whsec_full_secret",
770 "create_time": "2026-07-01T12:00:00Z",
771 "update_time": "2026-07-02T12:00:00Z"
772 });
773
774 let webhook: Webhook = serde_json::from_value(json.clone()).unwrap();
775 assert_eq!(webhook.id.as_deref(), Some("wh123bare0pq"));
776 assert_eq!(webhook.state, Some(WebhookState::Enabled));
777 assert_eq!(webhook.subscribed_events.len(), 3);
778 assert_eq!(
779 webhook.signing_secrets.as_ref().unwrap()[0]
780 .truncated_secret
781 .as_deref(),
782 Some("whsec_...abcd")
783 );
784
785 let back = serde_json::to_value(&webhook).unwrap();
786 assert_eq!(back, json);
787 }
788
789 #[test]
790 fn test_webhook_update_partial_serialization() {
791 let update = WebhookUpdate::new().with_state(WebhookState::Disabled);
792 let value = serde_json::to_value(&update).unwrap();
793 assert_eq!(value, json!({"state": "disabled"}));
794 }
795
796 #[test]
797 fn test_webhook_list_response_deserialization() {
798 let json = json!({
799 "webhooks": [
800 {"uri": "https://a.example.com", "subscribed_events": ["batch.failed"]}
801 ],
802 "next_page_token": "tok-1"
803 });
804 let list: WebhookListResponse = serde_json::from_value(json).unwrap();
805 assert_eq!(list.webhooks.len(), 1);
806 assert_eq!(list.next_page_token.as_deref(), Some("tok-1"));
807
808 let empty: WebhookListResponse = serde_json::from_str("{}").unwrap();
810 assert!(empty.webhooks.is_empty());
811 assert!(empty.next_page_token.is_none());
812
813 let null: WebhookListResponse = serde_json::from_value(json!({"webhooks": null})).unwrap();
816 assert!(null.webhooks.is_empty());
817 let bad: WebhookListResponse = serde_json::from_value(json!({"webhooks": 7})).unwrap();
818 assert!(bad.webhooks.is_empty());
819
820 let partial: WebhookListResponse = serde_json::from_value(json!({"webhooks": [
825 {"uri": "https://a.example.com", "create_time": "not-a-time"},
826 {"uri": 42},
827 {"uri": "https://b.example.com"}
828 ]}))
829 .unwrap();
830 assert_eq!(partial.webhooks.len(), 2);
831 assert_eq!(partial.webhooks[0].uri, "https://a.example.com");
832 assert!(partial.webhooks[0].create_time.is_none());
833 assert_eq!(partial.webhooks[1].uri, "https://b.example.com");
834 }
835
836 #[test]
837 fn test_rotate_signing_secret_response() {
838 let response: RotateSigningSecretResponse =
839 serde_json::from_str(r#"{"secret": "whsec_new"}"#).unwrap();
840 assert_eq!(response.secret.as_deref(), Some("whsec_new"));
841
842 let empty: RotateSigningSecretResponse = serde_json::from_str("{}").unwrap();
843 assert!(empty.secret.is_none());
844 }
845
846 #[test]
847 fn test_webhook_debug_redacts_new_signing_secret() {
848 let webhook = Webhook {
849 new_signing_secret: Some("whsec_super_secret".to_string()),
850 ..Webhook::new("https://example.com/hook", vec![])
851 };
852 let debug = format!("{webhook:?}");
853 assert!(!debug.contains("whsec_super_secret"));
854 assert!(debug.contains("[REDACTED]"));
855 let no_secret = Webhook::new("https://example.com/hook", vec![]);
857 assert!(!format!("{no_secret:?}").contains("[REDACTED]"));
858 }
859
860 #[test]
861 fn test_rotate_signing_secret_response_debug_redacts_secret() {
862 let response = RotateSigningSecretResponse {
863 secret: Some("whsec_rotated_secret".to_string()),
864 };
865 let debug = format!("{response:?}");
866 assert!(!debug.contains("whsec_rotated_secret"));
867 assert!(debug.contains("[REDACTED]"));
868 }
869
870 #[test]
871 fn test_webhook_config_serialization() {
872 let config = WebhookConfig::new()
873 .with_uris(vec!["https://example.com/hook".to_string()])
874 .with_user_metadata(json!({"job": "nightly"}));
875
876 let value = serde_json::to_value(&config).unwrap();
877 assert_eq!(
878 value,
879 json!({
880 "uris": ["https://example.com/hook"],
881 "user_metadata": {"job": "nightly"}
882 })
883 );
884 }
885
886 #[test]
887 fn test_webhook_config_empty_serializes_to_empty_object() {
888 let config = WebhookConfig::new();
889 assert_eq!(serde_json::to_string(&config).unwrap(), "{}");
890 }
891
892 #[test]
893 fn test_webhook_config_roundtrip() {
894 let config = WebhookConfig::new()
895 .with_uris(vec!["https://example.com/a".to_string()])
896 .with_user_metadata(json!({"k": [1, 2, 3]}));
897 let json = serde_json::to_string(&config).unwrap();
898 let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
899 assert_eq!(config, parsed);
900 }
901
902 #[test]
903 fn test_webhook_event_display() {
904 assert_eq!(
905 WebhookEvent::InteractionCompleted.to_string(),
906 "interaction.completed"
907 );
908 let unknown = WebhookEvent::Unknown {
909 event_type: "x.y".to_string(),
910 data: json!("x.y"),
911 };
912 assert_eq!(unknown.to_string(), "x.y");
913 }
914}