diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d6deb..8fa3960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ Notable changes to `dodomain-sdk`. The import package is `dodomain`. +## 0.5.0 + +Parity with `@dodomain/node` 0.7.0 and the `/v1` contract changes it tracked: +webhook-endpoint auto-pause (DoDomain PR #342) and the apps list's white-label +connect-flow settings. Purely additive, so upgrading from 0.4.0 is a drop-in. + +### Added + +* **`webhook_endpoints.resume(endpoint_id)`** (sync and async) — + `POST /api/v1/webhook-endpoints/{endpointId}/resume`. doDomain now pauses an + endpoint by itself once it has had no successful delivery for 7 days and at + least 5 dead-lettered deliveries in that span; this clears the pause and + restarts the 7-day clock. Idempotent: an endpoint that is not paused comes back + unchanged with a 200. It does not resend the deliveries skipped while paused — + redrive those from the dashboard. Another app's endpoint id is a 404, as + everywhere else. +* **`WebhookEndpoint.paused_at` / `WebhookEndpointWithSecret.paused_at`** — when + the endpoint was auto-paused, or `None` while it is delivering. Carried through + `WebhookEndpointWithSecret.endpoint`. +* **`App.connect_headline`, `.connect_subheadline`, `.connect_success_cta_label`, + `.connect_success_redirect_url`, `.connect_font_preset`, + `.hide_connect_footer_help`** — the white-label connect-flow settings the + apps list has returned since 2026-09-23, as stored (`None` / `False` until + configured). They render on the hosted flow only while the plan includes + white-label. +* **`ConnectFontPreset`** — the `Literal["system", "humanist", "serif", "rounded"]` + alias for `App.connect_font_preset`. + +### Changed + +* `webhook_endpoints.update` documents that a url which actually changes also + resumes an auto-paused endpoint. +* The OpenAPI contract guard (`tests/test_openapi_contract.py`) now also pins + `WebhookEndpointSummary` and `WebhookEndpointSecretResponse`, so a new required + field on either fails the suite when the fixture is regenerated. + +### Notes + +* New response fields parse tolerantly, as before: a body recorded before the + field existed reads as `None` / `False`, while a field present with the wrong + type still fails loudly. +* Delivery statuses (including the new `skipped`) are not part of the public + `/v1` contract and are not modelled by this SDK. + ## 0.4.0 Parity with `@dodomain/node` 0.5.0 and 0.6.0, and with the `/v1` contract changes diff --git a/README.md b/README.md index 5df8c9f..ecb9a90 100644 --- a/README.md +++ b/README.md @@ -254,12 +254,14 @@ endpoint = client.webhook_endpoints.create(url="https://acme.example/webhooks/do endpoint.secret # "whsec_…" — SHOWN ONCE. Store it now. for e in client.webhook_endpoints.list(): - print(e.id, e.url) # never carries a secret + print(e.id, e.url, e.paused_at) # never carries a secret; paused_at is None unless auto-paused client.webhook_endpoints.update("whe_123", url="https://acme.example/v2") # secret unchanged rotated = client.webhook_endpoints.rotate_secret("whe_123") rotated.secret # the new one, also shown once +client.webhook_endpoints.resume("whe_123") # clears paused_at; a no-op if it is not paused + client.webhook_endpoints.delete("whe_123") ``` @@ -275,6 +277,19 @@ Three things that will bite if assumed away: read-one route — so it costs one list request, and the `NotFoundError` it raises carries `status_code == 0` because no 404 came back from the server. +### Auto-paused endpoints + +doDomain pauses an endpoint by itself once it has had **no successful delivery +for 7 days and at least 5 dead-lettered deliveries** in that span; `paused_at` +holds when that happened. While paused, new events are still recorded as +deliveries but marked *skipped* and never sent. + +`resume(endpoint_id)` clears `paused_at` and restarts the 7-day clock. It is +idempotent — an endpoint that is not paused comes back unchanged, so a +reconciler may call it unconditionally. It does **not** resend the skipped +deliveries; redrive those from the dashboard. An `update` that actually changes +the url resumes the endpoint too. + ## Rotating your secret key ```python @@ -339,6 +354,7 @@ check.guide.steps # copy-ready manual instructions for app in client.apps.list(): print(app.id, app.name, app.public_key, app.sandbox) print(app.tls_issuer_ca) # the CA your certificates are issued with, or None + print(app.connect_headline, app.connect_font_preset) # white-label settings, or None ``` A secret key sees exactly its own app — listing siblings would widen a single diff --git a/src/dodomain/__init__.py b/src/dodomain/__init__.py index 0ccf29b..7253b9c 100644 --- a/src/dodomain/__init__.py +++ b/src/dodomain/__init__.py @@ -20,7 +20,7 @@ from __future__ import annotations -__version__ = "0.4.0" +__version__ = "0.5.0" from ._client import AsyncDoDomain, DoDomain from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot @@ -46,6 +46,7 @@ CheckDomainResult, ComposedRecord, Confidence, + ConnectFontPreset, Connection, ConnectionPage, ConnectionStatus, @@ -89,6 +90,7 @@ "ComposedRecord", "Confidence", "ConflictError", + "ConnectFontPreset", "Connection", "ConnectionPage", "ConnectionStatus", diff --git a/src/dodomain/models.py b/src/dodomain/models.py index 7dfb6ae..a6ae690 100644 --- a/src/dodomain/models.py +++ b/src/dodomain/models.py @@ -29,6 +29,7 @@ "CheckDomainResult", "ComposedRecord", "Confidence", + "ConnectFontPreset", "Connection", "ConnectionPage", "ConnectionStatus", @@ -88,6 +89,10 @@ #: ``packages/core/src/schemas.ts``; the server refuses anything else. RotationOverlapHours = Literal[0, 1, 24] +#: The hosted connect flow's white-label typeface. Transcribed from +#: ``CONNECT_FONT_PRESETS`` in the app repo (``packages/core/src/schemas.ts``). +ConnectFontPreset = Literal["system", "humanist", "serif", "rounded"] + #: Every DNS record type a connect session may request, from the app repo's one #: record-type home (``packages/core/src/record-capabilities.ts``). RECORD_TYPES: tuple[str, ...] = ("A", "AAAA", "CNAME", "TXT", "MX") @@ -106,6 +111,10 @@ #: ``Literal`` above only makes at type-check time. OVERLAP_HOURS_VALUES: tuple[int, ...] = (0, 1, 24) +#: The font presets, for the runtime check :data:`ConnectFontPreset` only makes +#: at type-check time. +CONNECT_FONT_PRESETS: tuple[str, ...] = ("system", "humanist", "serif", "rounded") + # ── payload readers ───────────────────────────────────────────────────────── @@ -143,6 +152,15 @@ def _req_bool(payload: dict[str, Any], key: str) -> bool: return value +def _opt_bool(payload: dict[str, Any], key: str) -> bool | None: + value = payload.get(key) + if value is None: + return None + if not isinstance(value, bool): + raise _fail(f"field {key!r} should be a boolean or null", payload) + return value + + def _req_int(payload: dict[str, Any], key: str) -> int: value = payload.get(key) if isinstance(value, bool) or not isinstance(value, int): @@ -899,6 +917,16 @@ class App: #: rather than the weaker ``caa_restricts_issuance``. Additive on the wire, #: so it carries a default and sits after the original fields. tls_issuer_ca: str | None = None + #: White-label connect-flow settings (Pro and Scale), as stored; ``None`` until + #: configured. They render on the hosted connect flow only while your plan + #: includes white-label. Additive on the wire, so they carry defaults. + connect_headline: str | None = None + connect_subheadline: str | None = None + connect_success_cta_label: str | None = None + #: Where the success button goes when a session has no ``return_url`` of its own. + connect_success_redirect_url: str | None = None + connect_font_preset: ConnectFontPreset | None = None + hide_connect_footer_help: bool = False raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -913,6 +941,12 @@ def _from_api(cls, payload: Any) -> App: brand_color=_opt_str(data, "brandColor"), created_at=_req_datetime(data, "createdAt"), tls_issuer_ca=_opt_str(data, "tlsIssuerCa"), + connect_headline=_opt_str(data, "connectHeadline"), + connect_subheadline=_opt_str(data, "connectSubheadline"), + connect_success_cta_label=_opt_str(data, "connectSuccessCtaLabel"), + connect_success_redirect_url=_opt_str(data, "connectSuccessRedirectUrl"), + connect_font_preset=_literal(data, "connectFontPreset", (*CONNECT_FONT_PRESETS, None)), + hide_connect_footer_help=_opt_bool(data, "hideConnectFooterHelp") or False, raw=data, ) @@ -976,6 +1010,12 @@ class WebhookEndpoint: #: trailing-slash variant comes back canonical. url: str created_at: datetime + #: When doDomain AUTO-PAUSED this endpoint — no successful delivery for 7 days + #: and at least 5 dead-lettered deliveries in that span — or ``None`` while it + #: is delivering normally. While paused, new events are recorded as *skipped* + #: deliveries and never sent, until ``webhook_endpoints.resume`` (or an + #: ``update`` that actually changes the url) clears it. + paused_at: datetime | None = None raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -986,6 +1026,7 @@ def _from_api(cls, payload: Any) -> WebhookEndpoint: app_id=_req_str(data, "appId"), url=_req_str(data, "url"), created_at=_req_datetime(data, "createdAt"), + paused_at=_opt_datetime(data, "pausedAt"), raw=data, ) @@ -1016,6 +1057,8 @@ class WebhookEndpointWithSecret: #: ``whsec_…`` — store it now. Kept out of ``repr`` so an exception traceback #: or a debug print of this object cannot spill the signing secret into a log. secret: str = field(repr=False) + #: See :attr:`WebhookEndpoint.paused_at`. + paused_at: datetime | None = None raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @property @@ -1026,6 +1069,7 @@ def endpoint(self) -> WebhookEndpoint: app_id=self.app_id, url=self.url, created_at=self.created_at, + paused_at=self.paused_at, raw=self.raw, ) @@ -1038,6 +1082,7 @@ def _from_api(cls, payload: Any) -> WebhookEndpointWithSecret: url=_req_str(data, "url"), created_at=_req_datetime(data, "createdAt"), secret=_req_str(data, "secret"), + paused_at=_opt_datetime(data, "pausedAt"), raw=data, ) diff --git a/src/dodomain/resources/webhook_endpoints.py b/src/dodomain/resources/webhook_endpoints.py index 244c706..1aca063 100644 --- a/src/dodomain/resources/webhook_endpoints.py +++ b/src/dodomain/resources/webhook_endpoints.py @@ -83,6 +83,14 @@ def _spec_update(endpoint_id: str, url: str, idempotency_key: str | None) -> Req ) +def _spec_resume(endpoint_id: str, idempotency_key: str | None) -> RequestSpec: + # A verb sub-path with no body, like rotate-secret: resuming is an action, not a + # property you set. + return RequestSpec( + "POST", _endpoint_path(endpoint_id, "/resume"), idempotency_key=idempotency_key + ) + + def _parse_list(payload: Any) -> tuple[WebhookEndpoint, ...]: items = payload.get("endpoints") if isinstance(payload, dict) else None if not isinstance(items, list): @@ -162,7 +170,8 @@ def update( """Repoint an endpoint at a new URL. The signing secret is untouched — moving hosts must not force a receiver to - re-key. ``url`` is the only mutable field an endpoint has. + re-key. ``url`` is the only mutable field an endpoint has. A ``url`` that + actually changes also resumes an auto-paused endpoint (see :meth:`resume`). """ return WebhookEndpoint._from_api( self._client.request(_spec_update(endpoint_id, url, idempotency_key)) @@ -202,6 +211,24 @@ def rotate_secret( ) ) + def resume(self, endpoint_id: str, *, idempotency_key: str | None = None) -> WebhookEndpoint: + """Resume an endpoint doDomain paused automatically. + + doDomain pauses an endpoint once it has had no successful delivery for 7 + days **and** at least 5 dead-lettered deliveries in that span; + :attr:`~dodomain.models.WebhookEndpoint.paused_at` is set while it is. This + clears ``paused_at`` and restarts the 7-day clock. + + **Idempotent:** an endpoint that is not paused comes back unchanged (a 200, + not an error). Events that happened while it was paused were recorded as + *skipped* deliveries and are **not** resent by this call — redrive them + from the dashboard. An :meth:`update` that changes the url resumes the + endpoint too. + """ + return WebhookEndpoint._from_api( + self._client.request(_spec_resume(endpoint_id, idempotency_key)) + ) + class AsyncWebhookEndpoints: """``client.webhook_endpoints`` on :class:`~dodomain.AsyncDoDomain`.""" @@ -257,3 +284,11 @@ async def rotate_secret( ) ) ) + + async def resume( + self, endpoint_id: str, *, idempotency_key: str | None = None + ) -> WebhookEndpoint: + """Resume an auto-paused endpoint. See :meth:`WebhookEndpoints.resume`.""" + return WebhookEndpoint._from_api( + await self._client.request(_spec_resume(endpoint_id, idempotency_key)) + ) diff --git a/tests/fixtures/openapi_v1_shapes.json b/tests/fixtures/openapi_v1_shapes.json index 77cafbe..3bf3636 100644 --- a/tests/fixtures/openapi_v1_shapes.json +++ b/tests/fixtures/openapi_v1_shapes.json @@ -499,11 +499,70 @@ } ] }, + "connectFontPreset": { + "anyOf": [ + { + "enum": [ + "system", + "humanist", + "serif", + "rounded" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "connectHeadline": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "connectSubheadline": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "connectSuccessCtaLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "connectSuccessRedirectUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "createdAt": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, + "hideConnectFooterHelp": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -544,6 +603,12 @@ "sandbox", "logoUrl", "brandColor", + "connectHeadline", + "connectSubheadline", + "connectSuccessCtaLabel", + "connectSuccessRedirectUrl", + "connectFontPreset", + "hideConnectFooterHelp", "tlsIssuerCa", "createdAt" ], @@ -556,6 +621,86 @@ "apps" ], "type": "object" + }, + "WebhookEndpointSummary": { + "properties": { + "appId": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "pausedAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "appId", + "url", + "createdAt", + "pausedAt" + ], + "type": "object" + }, + "WebhookEndpointSecretResponse": { + "properties": { + "appId": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "pausedAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "secret": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "appId", + "url", + "createdAt", + "pausedAt", + "secret" + ], + "type": "object" } } } diff --git a/tests/helpers.py b/tests/helpers.py index 3e21bf2..5d4cbbc 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -226,6 +226,7 @@ def webhook_endpoint(**overrides: Any) -> dict[str, Any]: "appId": "app_1", "url": "https://acme.example/webhooks/dodomain", "createdAt": "2026-08-01T09:00:00.000Z", + "pausedAt": None, } payload.update(overrides) return payload @@ -268,16 +269,39 @@ def webhook_endpoint_with_secret(**overrides: Any) -> dict[str, Any]: "sandbox": False, "logoUrl": None, "brandColor": "#0E6B4E", + "connectHeadline": "Connect your domain to Acme", + "connectSubheadline": None, + "connectSuccessCtaLabel": "Back to Acme", + "connectSuccessRedirectUrl": "https://acme.example/domains", + "connectFontPreset": "humanist", + "hideConnectFooterHelp": True, "tlsIssuerCa": "letsencrypt.org", "createdAt": "2026-07-01T00:00:00.000Z", } ] } -#: The apps list exactly as the API shipped it before `tlsIssuerCa` existed. +#: The white-label connect-flow fields the API added after `tlsIssuerCa`. +WHITE_LABEL_APP_KEYS = frozenset( + { + "connectHeadline", + "connectSubheadline", + "connectSuccessCtaLabel", + "connectSuccessRedirectUrl", + "connectFontPreset", + "hideConnectFooterHelp", + } +) + +#: The apps list exactly as the API shipped it before `tlsIssuerCa` existed (and +#: so before the white-label fields, which came later still). LEGACY_LIST_APPS_RESPONSE: dict[str, Any] = { "apps": [ - {key: value for key, value in LIST_APPS_RESPONSE["apps"][0].items() if key != "tlsIssuerCa"} + { + key: value + for key, value in LIST_APPS_RESPONSE["apps"][0].items() + if key != "tlsIssuerCa" and key not in WHITE_LABEL_APP_KEYS + } ] } diff --git a/tests/test_apps.py b/tests/test_apps.py index a067c0f..17ed235 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -7,7 +7,15 @@ import respx from dodomain import InvalidResponseError, PermissionError_ -from tests.helpers import LIST_APPS_RESPONSE, TEST_JWT, TEST_KEY, api, make_client +from dodomain.models import App +from tests.helpers import ( + LEGACY_LIST_APPS_RESPONSE, + LIST_APPS_RESPONSE, + TEST_JWT, + TEST_KEY, + api, + make_client, +) SECRET_FIELD_NAMES = ("secretKeyHash", "secretKey", "secret_key", "secret_key_hash") @@ -30,6 +38,35 @@ def test_list_returns_parsed_apps_with_a_tz_aware_created_at() -> None: assert apps[0].created_at == datetime(2026, 7, 1, tzinfo=timezone.utc) +@respx.mock +def test_list_parses_the_white_label_connect_flow_settings() -> None: + respx.get(api("/api/v1/apps")).mock(return_value=httpx.Response(200, json=LIST_APPS_RESPONSE)) + with make_client() as client: + app = client.apps.list()[0] + assert app.connect_headline == "Connect your domain to Acme" + assert app.connect_subheadline is None + assert app.connect_success_cta_label == "Back to Acme" + assert app.connect_success_redirect_url == "https://acme.example/domains" + assert app.connect_font_preset == "humanist" + assert app.hide_connect_footer_help is True + + +def test_an_app_recorded_before_white_label_existed_parses_with_unset_settings() -> None: + app = App._from_api(LEGACY_LIST_APPS_RESPONSE["apps"][0]) + assert app.connect_headline is None + assert app.connect_font_preset is None + assert app.hide_connect_footer_help is False + + +@pytest.mark.parametrize( + ("key", "value"), + [("connectFontPreset", "comic-sans"), ("hideConnectFooterHelp", "yes")], +) +def test_a_white_label_field_of_the_wrong_shape_fails_loudly(key: str, value: object) -> None: + with pytest.raises(InvalidResponseError): + App._from_api({**LIST_APPS_RESPONSE["apps"][0], key: value}) + + @respx.mock def test_the_app_model_carries_no_secret_material_even_if_the_api_regressed() -> None: leaked = {**LIST_APPS_RESPONSE["apps"][0], "secretKeyHash": "should-never-be-modelled"} diff --git a/tests/test_async_parity.py b/tests/test_async_parity.py index 095ae4d..41ab698 100644 --- a/tests/test_async_parity.py +++ b/tests/test_async_parity.py @@ -231,6 +231,17 @@ lambda c: c.webhook_endpoints.rotate_secret("whe_1"), lambda c: c.webhook_endpoints.rotate_secret("whe_1"), ), + ( + "webhook_endpoints.resume", + lambda: ( + respx.post(api("/api/v1/webhook-endpoints/whe_1/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint()) + ) + and None + ), + lambda c: c.webhook_endpoints.resume("whe_1"), + lambda c: c.webhook_endpoints.resume("whe_1"), + ), ( "keys.rotate", lambda: ( diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py index 9c85956..0223818 100644 --- a/tests/test_openapi_contract.py +++ b/tests/test_openapi_contract.py @@ -23,7 +23,8 @@ node -e "const fs=require('fs'); const spec=JSON.parse(fs.readFileSync('apps/docs/public/openapi.json','utf8')); const want=['CreateSessionResponse','PublicSession','IntegratorSession', - 'VerifySessionResponse','ListAppsResponse']; + 'VerifySessionResponse','ListAppsResponse', + 'WebhookEndpointSummary','WebhookEndpointSecretResponse']; const old=JSON.parse(fs.readFileSync(DEST,'utf8')); fs.writeFileSync(DEST, JSON.stringify({...old, apiVersion: spec.info.version, schemas: Object.fromEntries(want.map(k=>[k,spec.components.schemas[k]]))}, null, 2)+'\\n');" @@ -44,6 +45,8 @@ IntegratorSession, PublicSession, VerifyResult, + WebhookEndpoint, + WebhookEndpointWithSecret, ) SCHEMAS: dict[str, Any] = json.loads( @@ -101,9 +104,30 @@ "sandbox": "sandbox", "logoUrl": "logo_url", "brandColor": "brand_color", + "connectHeadline": "connect_headline", + "connectSubheadline": "connect_subheadline", + "connectSuccessCtaLabel": "connect_success_cta_label", + "connectSuccessRedirectUrl": "connect_success_redirect_url", + "connectFontPreset": "connect_font_preset", + "hideConnectFooterHelp": "hide_connect_footer_help", "tlsIssuerCa": "tls_issuer_ca", "createdAt": "created_at", }, + "WebhookEndpointSummary": { + "id": "id", + "appId": "app_id", + "url": "url", + "createdAt": "created_at", + "pausedAt": "paused_at", + }, + "WebhookEndpointSecretResponse": { + "id": "id", + "appId": "app_id", + "url": "url", + "createdAt": "created_at", + "pausedAt": "paused_at", + "secret": "secret", + }, } @@ -163,6 +187,8 @@ def test_every_required_field_of_the_published_contract_has_a_model_field(name: "IntegratorSession": IntegratorSession._from_api, "VerifySessionResponse": VerifyResult._from_api, "ListAppsResponse.apps.items": App._from_api, + "WebhookEndpointSummary": WebhookEndpoint._from_api, + "WebhookEndpointSecretResponse": WebhookEndpointWithSecret._from_api, } diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 3bd1e18..bc2af59 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -201,6 +201,9 @@ def test_the_webhook_endpoints_block_runs() -> None: respx.delete(api("/api/v1/webhook-endpoints/whe_123")).mock( return_value=httpx.Response(200, json={"id": "whe_123", "deleted": True}) ) + respx.post(api("/api/v1/webhook-endpoints/whe_123/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint(id="whe_123")) + ) with make_client() as client: endpoint = client.webhook_endpoints.create(url="https://acme.example/webhooks/dodomain") assert endpoint.secret.startswith("whsec_") @@ -210,6 +213,7 @@ def test_the_webhook_endpoints_block_runs() -> None: client.webhook_endpoints.update("whe_123", url="https://acme.example/v2") rotated = client.webhook_endpoints.rotate_secret("whe_123") assert rotated.secret.startswith("whsec_") + assert client.webhook_endpoints.resume("whe_123").paused_at is None assert client.webhook_endpoints.delete("whe_123").deleted is True diff --git a/tests/test_version.py b/tests/test_version.py index 13ddeba..df8ff52 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,7 +4,7 @@ def test_version_is_the_single_source_of_truth() -> None: - assert dodomain.__version__ == "0.4.0" + assert dodomain.__version__ == "0.5.0" def test_the_user_agent_reports_that_same_version() -> None: diff --git a/tests/test_webhook_endpoints.py b/tests/test_webhook_endpoints.py index e72c103..7b1cf69 100644 --- a/tests/test_webhook_endpoints.py +++ b/tests/test_webhook_endpoints.py @@ -169,6 +169,127 @@ def test_rotate_secret_posts_to_the_verb_subpath_and_returns_the_new_secret() -> assert rotated.id == "whe_1" +# ── auto-pause: paused_at + resume() ───────────────────────────────────────── + + +@respx.mock +def test_resume_posts_the_verb_subpath_with_no_body_and_returns_the_resumed_endpoint() -> None: + route = respx.post(api("/api/v1/webhook-endpoints/whe_1/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint()) + ) + with make_client() as client: + resumed = client.webhook_endpoints.resume("whe_1") + assert route.call_count == 1 + assert route.calls[0].request.method == "POST" + # Resuming is an action, not a property to send. + assert route.calls[0].request.content == b"" + assert isinstance(resumed, WebhookEndpoint) + assert not isinstance(resumed, WebhookEndpointWithSecret) + assert resumed.id == "whe_1" + assert resumed.paused_at is None + + +@respx.mock +def test_resume_on_an_endpoint_that_is_not_paused_is_a_plain_200_not_an_error() -> None: + # Idempotent server-side: the unchanged endpoint comes back, so calling it + # "just in case" from a reconciler is safe. + route = respx.post(api("/api/v1/webhook-endpoints/whe_1/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint()) + ) + with make_client() as client: + first = client.webhook_endpoints.resume("whe_1") + second = client.webhook_endpoints.resume("whe_1") + assert route.call_count == 2 + assert first == second + + +@respx.mock +def test_resume_forwards_an_idempotency_key() -> None: + route = respx.post(api("/api/v1/webhook-endpoints/whe_1/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint()) + ) + with make_client() as client: + client.webhook_endpoints.resume("whe_1", idempotency_key="resume-whe_1-attempt-1") + assert route.calls[0].request.headers["idempotency-key"] == "resume-whe_1-attempt-1" + + +@respx.mock +def test_resume_url_encodes_the_endpoint_id() -> None: + route = respx.post(api("/api/v1/webhook-endpoints/whe%2F1/resume")).mock( + return_value=httpx.Response(200, json=webhook_endpoint(id="whe/1")) + ) + with make_client() as client: + client.webhook_endpoints.resume("whe/1") + assert route.call_count == 1 + + +@respx.mock +def test_resuming_another_apps_endpoint_is_a_404() -> None: + respx.post(api("/api/v1/webhook-endpoints/whe_someone_else/resume")).mock( + return_value=httpx.Response(404, json={"error": "not_found"}) + ) + with pytest.raises(NotFoundError) as excinfo, make_client() as client: + client.webhook_endpoints.resume("whe_someone_else") + assert excinfo.value.status_code == 404 + + +@respx.mock +@pytest.mark.parametrize("bad_id", ["", " "]) +def test_resume_refuses_an_empty_endpoint_id_before_any_request(bad_id: str) -> None: + route = respx.post(url__regex=r".*/resume$").mock( + return_value=httpx.Response(200, json=webhook_endpoint()) + ) + with pytest.raises(InvalidRequestError) as excinfo, make_client() as client: + client.webhook_endpoints.resume(bad_id) + assert excinfo.value.status_code == 0 + assert route.call_count == 0 + + +@respx.mock +def test_a_paused_endpoints_paused_at_survives_the_list_parse() -> None: + respx.get(COLLECTION).mock( + return_value=httpx.Response( + 200, json={"endpoints": [webhook_endpoint(pausedAt="2026-10-08T09:00:00.000Z")]} + ) + ) + with make_client() as client: + endpoint = client.webhook_endpoints.list()[0] + assert endpoint.paused_at == datetime(2026, 10, 8, 9, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_a_body_recorded_before_paused_at_existed_still_parses_as_not_paused() -> None: + legacy = webhook_endpoint() + del legacy["pausedAt"] + respx.get(COLLECTION).mock(return_value=httpx.Response(200, json={"endpoints": [legacy]})) + with make_client() as client: + endpoint = client.webhook_endpoints.list()[0] + assert endpoint.paused_at is None + + +@respx.mock +def test_a_paused_at_of_the_wrong_type_fails_loudly() -> None: + respx.get(COLLECTION).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint(pausedAt=1)]}) + ) + with pytest.raises(InvalidResponseError), make_client() as client: + client.webhook_endpoints.list() + + +@respx.mock +def test_the_secret_bearing_result_carries_paused_at_into_its_loggable_summary() -> None: + respx.post(api("/api/v1/webhook-endpoints/whe_1/rotate-secret")).mock( + return_value=httpx.Response( + 200, json=webhook_endpoint_with_secret(pausedAt="2026-10-08T09:00:00.000Z") + ) + ) + with make_client() as client: + rotated = client.webhook_endpoints.rotate_secret("whe_1") + paused = datetime(2026, 10, 8, 9, 0, tzinfo=timezone.utc) + assert rotated.paused_at == paused + assert rotated.endpoint.paused_at == paused + + @respx.mock def test_an_oauth_token_is_refused_with_a_readable_secret_key_required_signal() -> None: # 403 rather than 401: the token is valid, it just has no authority here, and