From adb300385b1822f8a9f776af9a787fc2cc7b25d9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 10:42:50 -0700 Subject: [PATCH] fix: accept both event names and source names for webhooks Fixes #1597 Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/webhook_item.py | 11 ++- test/assets/webhook_get_new_event.xml | 14 ++++ test/test_webhook.py | 80 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 test/assets/webhook_get_new_event.xml diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index c8857bb75..a8dfe3ef0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -72,12 +72,17 @@ def id(self) -> str | None: @property def event(self) -> str | None: if self._event: - return self._event.replace("webhook-source-event-", "") + return self._event.removeprefix("webhook-source-event-") return None @event.setter - def event(self, value: str) -> None: - self._event = f"webhook-source-event-{value}" + def event(self, value: str | None) -> None: + if value is None: + self._event = None + elif value.startswith("webhook-source-event-") or value.startswith("webhook-event-"): + self._event = value + else: + self._event = f"webhook-source-event-{value}" @classmethod def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: diff --git a/test/assets/webhook_get_new_event.xml b/test/assets/webhook_get_new_event.xml new file mode 100644 index 000000000..776a7ac99 --- /dev/null +++ b/test/assets/webhook_get_new_event.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/test_webhook.py b/test/test_webhook.py index e0217e93f..4fa011da0 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = TEST_ASSET_DIR / "webhook_get.xml" +GET_NEW_EVENT_XML = TEST_ASSET_DIR / "webhook_get_new_event.xml" CREATE_XML = TEST_ASSET_DIR / "webhook_create.xml" CREATE_REQUEST_XML = TEST_ASSET_DIR / "webhook_create_request.xml" @@ -87,3 +88,82 @@ def test_request_factory(): webhook_request_actual = "{}\n".format(RequestFactory.Webhook.create_req(webhook_item).decode("utf-8")) # windows does /r/n for linebreaks, remove the extra char if it is there assert webhook_request_expected.replace("\r", "") == webhook_request_actual + + +def test_event_setter_none(): + """Setting event to None should store None without crashing.""" + item = WebhookItem() + item.event = "datasource-updated" + assert item.event == "datasource-updated" + item.event = None + assert item._event is None + assert item.event is None + + +def test_event_setter_short_name(): + """Short event names should be stored with the webhook-source-event- prefix.""" + item = WebhookItem() + item.event = "datasource-updated" + assert item._event == "webhook-source-event-datasource-updated" + assert item.event == "datasource-updated" + + +def test_event_setter_full_source_name(): + """Full webhook-source-event- names should be accepted and stored as-is.""" + item = WebhookItem() + item.event = "webhook-source-event-datasource-updated" + assert item._event == "webhook-source-event-datasource-updated" + assert item.event == "datasource-updated" + + +def test_event_setter_new_style_event_name(): + """New-style event names (webhook-event-*) should be stored as-is and not mangled.""" + item = WebhookItem() + item.event = "webhook-event-user-promoted-admin" + assert item._event == "webhook-event-user-promoted-admin" + assert item.event == "webhook-event-user-promoted-admin" + + +def test_get_new_style_event(server: TSC.Server) -> None: + """Webhooks with new-style event names (webhook-event-*) should parse correctly.""" + response_xml = GET_NEW_EVENT_XML.read_text() + with requests_mock.mock() as m: + m.get(server.webhooks.baseurl, text=response_xml) + webhooks, _ = server.webhooks.get() + assert len(webhooks) == 1 + webhook = webhooks[0] + + assert webhook.id == "webhook-id-2" + assert webhook.name == "webhook-name-2" + assert webhook.url == "https://example.com/hook" + # New-style event name should not have the webhook-source-event- prefix stripped + assert webhook.event == "webhook-event-user-promoted-admin" + assert webhook.owner_id == "webhook_owner_luid" + + +def test_create_with_short_event_name(server: TSC.Server) -> None: + """Creating a webhook with a short event name (e.g. datasource-created) should work.""" + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.webhooks.baseurl, text=response_xml) + webhook_model = TSC.WebhookItem() + webhook_model.name = "Test Webhook" + webhook_model.url = "https://ifttt.com/maker-url" + webhook_model.event = "datasource-created" + + new_webhook = server.webhooks.create(webhook_model) + assert new_webhook.id is not None + + +def test_create_with_source_event_name(server: TSC.Server) -> None: + """Creating a webhook with a full webhook-source-event-* name should work.""" + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.webhooks.baseurl, text=response_xml) + webhook_model = TSC.WebhookItem() + webhook_model.name = "Test Webhook" + webhook_model.url = "https://ifttt.com/maker-url" + webhook_model.event = "webhook-source-event-datasource-created" + + new_webhook = server.webhooks.create(webhook_model) + assert new_webhook.id is not None