hookbase 2.6.0__tar.gz → 2.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {hookbase-2.6.0 → hookbase-2.8.0}/PKG-INFO +2 -2
  2. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_client.py +8 -2
  3. hookbase-2.8.0/src/hookbase/_version.py +1 -0
  4. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/errors.py +9 -2
  5. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/__init__.py +2 -0
  6. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/cron_jobs.py +29 -7
  7. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/destinations.py +6 -3
  8. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/cron_jobs.py +11 -0
  9. hookbase-2.8.0/tests/resources/test_cron_jobs.py +160 -0
  10. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_destinations.py +15 -1
  11. {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_errors.py +47 -9
  12. hookbase-2.6.0/src/hookbase/_version.py +0 -1
  13. {hookbase-2.6.0 → hookbase-2.8.0}/.github/workflows/python-publish.yml +0 -0
  14. {hookbase-2.6.0 → hookbase-2.8.0}/.gitignore +0 -0
  15. {hookbase-2.6.0 → hookbase-2.8.0}/LICENSE +0 -0
  16. {hookbase-2.6.0 → hookbase-2.8.0}/README.md +0 -0
  17. {hookbase-2.6.0 → hookbase-2.8.0}/examples/async_usage.py +0 -0
  18. {hookbase-2.6.0 → hookbase-2.8.0}/examples/basic_inbound.py +0 -0
  19. {hookbase-2.6.0 → hookbase-2.8.0}/examples/error_handling.py +0 -0
  20. {hookbase-2.6.0 → hookbase-2.8.0}/examples/send_webhooks.py +0 -0
  21. {hookbase-2.6.0 → hookbase-2.8.0}/examples/verify_webhooks.py +0 -0
  22. {hookbase-2.6.0 → hookbase-2.8.0}/pyproject.toml +0 -0
  23. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/__init__.py +0 -0
  24. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_constants.py +0 -0
  25. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_pagination.py +0 -0
  26. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/client.py +0 -0
  27. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/_base.py +0 -0
  28. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/analytics.py +0 -0
  29. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/api_keys.py +0 -0
  30. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/applications.py +0 -0
  31. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/common.py +0 -0
  32. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/deliveries.py +0 -0
  33. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/dlq.py +0 -0
  34. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/endpoints.py +0 -0
  35. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/event_types.py +0 -0
  36. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/events.py +0 -0
  37. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/filters.py +0 -0
  38. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/messages.py +0 -0
  39. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/organizations.py +0 -0
  40. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/portal_tokens.py +0 -0
  41. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/routes.py +0 -0
  42. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/schemas.py +0 -0
  43. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/sources.py +0 -0
  44. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/subscriptions.py +0 -0
  45. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/transforms.py +0 -0
  46. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/tunnels.py +0 -0
  47. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/py.typed +0 -0
  48. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/__init__.py +0 -0
  49. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/_base.py +0 -0
  50. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/analytics.py +0 -0
  51. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/api_keys.py +0 -0
  52. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/applications.py +0 -0
  53. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/deliveries.py +0 -0
  54. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/destinations.py +0 -0
  55. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/dlq.py +0 -0
  56. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/endpoints.py +0 -0
  57. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/event_types.py +0 -0
  58. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/events.py +0 -0
  59. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/filters.py +0 -0
  60. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/message_log.py +0 -0
  61. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/messages.py +0 -0
  62. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/organizations.py +0 -0
  63. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/portal_tokens.py +0 -0
  64. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/routes.py +0 -0
  65. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/schemas.py +0 -0
  66. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/sources.py +0 -0
  67. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/subscriptions.py +0 -0
  68. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/transforms.py +0 -0
  69. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/tunnels.py +0 -0
  70. {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/webhook.py +0 -0
  71. {hookbase-2.6.0 → hookbase-2.8.0}/tests/__init__.py +0 -0
  72. {hookbase-2.6.0 → hookbase-2.8.0}/tests/conftest.py +0 -0
  73. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/__init__.py +0 -0
  74. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_applications.py +0 -0
  75. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_dlq.py +0 -0
  76. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_messages.py +0 -0
  77. {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_sources.py +0 -0
  78. {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_client.py +0 -0
  79. {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_pagination.py +0 -0
  80. {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 2.6.0
3
+ Version: 2.8.0
4
4
  Summary: Official Python SDK for the Hookbase webhook management platform
5
5
  Project-URL: Homepage, https://hookbase.app
6
6
  Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
@@ -27,9 +27,15 @@ logger = logging.getLogger("hookbase")
27
27
  def _parse_error(status_code: int, body: dict[str, Any], request_id: str | None) -> APIError:
28
28
  error = body.get("error", body)
29
29
  if isinstance(error, str):
30
+ # This is the shape the real API actually sends: `error` is a plain string, with
31
+ # `code` and `details` (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as
32
+ # siblings at the top level of the body, not nested inside `error`.
30
33
  message = error
31
- code = "unknown_error"
32
- validation_errors = None
34
+ code = body.get("code", "unknown_error")
35
+ details = body.get("details")
36
+ validation_errors = (
37
+ details.get("fieldErrors") if isinstance(details, dict) else None
38
+ )
33
39
  elif isinstance(error, dict):
34
40
  message = error.get("message") or body.get("message") or f"API error: {status_code}"
35
41
  code = error.get("code", "unknown_error")
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0"
@@ -38,8 +38,11 @@ class APIError(HookbaseError):
38
38
  ) -> APIError:
39
39
  error = body.get("error", body)
40
40
  if isinstance(error, str):
41
+ # The real API always sends `error` as a plain string, with `code` and `details`
42
+ # (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as siblings of `error`
43
+ # at the top level of the body — never nested inside it.
41
44
  message = error
42
- code = "unknown_error"
45
+ code = body.get("code", "unknown_error")
43
46
  elif isinstance(error, dict):
44
47
  message = error.get("message", f"API error: {status_code}")
45
48
  code = error.get("code", "unknown_error")
@@ -49,7 +52,11 @@ class APIError(HookbaseError):
49
52
 
50
53
  if status_code in (400, 422):
51
54
  validation_errors = None
52
- if isinstance(error, dict):
55
+ if isinstance(error, str):
56
+ details = body.get("details")
57
+ if isinstance(details, dict):
58
+ validation_errors = details.get("fieldErrors")
59
+ elif isinstance(error, dict):
53
60
  validation_errors = error.get("validationErrors")
54
61
  return ValidationError(
55
62
  message=message,
@@ -7,6 +7,7 @@ from .cron_jobs import (
7
7
  CreateCronJobParams,
8
8
  CronGroup,
9
9
  CronJob,
10
+ CronJobExecution,
10
11
  UpdateCronJobParams,
11
12
  )
12
13
  from .deliveries import (
@@ -123,6 +124,7 @@ __all__ = [
123
124
  "CreateCronJobParams",
124
125
  "CronGroup",
125
126
  "CronJob",
127
+ "CronJobExecution",
126
128
  "UpdateCronJobParams",
127
129
  # Deliveries
128
130
  "BulkReplayResult",
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from typing import Literal
4
5
 
5
6
  from pydantic import field_validator
6
7
 
7
8
  from ._base import HookbaseModel
8
9
 
10
+ CronJobExecutionStatus = Literal["success", "failed"]
11
+
9
12
 
10
13
  class CronJob(HookbaseModel):
11
14
  id: str
@@ -15,15 +18,19 @@ class CronJob(HookbaseModel):
15
18
  url: str = ""
16
19
  method: str = "GET"
17
20
  headers: dict[str, str] | None = None
18
- body: str | None = None
19
- schedule: str = ""
21
+ payload: str | None = None
22
+ cron_expression: str = ""
20
23
  timezone: str = "UTC"
24
+ timeout_ms: int = 30000
21
25
  is_active: bool = True
22
26
  use_static_ip: bool = True
23
27
  group_id: str | None = None
24
28
  last_run_at: str | None = None
25
29
  next_run_at: str | None = None
26
- last_status: str | None = None
30
+ consecutive_failures: int = 0
31
+ notify_on_failure: bool = False
32
+ notify_on_success: bool = False
33
+ notify_emails: str | None = None
27
34
  created_at: str = ""
28
35
  updated_at: str = ""
29
36
 
@@ -40,18 +47,29 @@ class CronJob(HookbaseModel):
40
47
  return v
41
48
 
42
49
 
50
+ class CronJobExecution(HookbaseModel):
51
+ id: str
52
+ status: CronJobExecutionStatus = "success"
53
+ response_status: int | None = None
54
+ latency_ms: int = 0
55
+
56
+
43
57
  class CreateCronJobParams(HookbaseModel):
44
58
  name: str
45
59
  description: str | None = None
46
60
  url: str
47
61
  method: str | None = None
48
62
  headers: dict[str, str] | None = None
49
- body: str | None = None
50
- schedule: str
63
+ payload: str | None = None
64
+ cron_expression: str
51
65
  timezone: str | None = None
66
+ timeout_ms: int | None = None
52
67
  is_active: bool | None = None
53
68
  use_static_ip: bool | None = None
54
69
  group_id: str | None = None
70
+ notify_on_failure: bool | None = None
71
+ notify_on_success: bool | None = None
72
+ notify_emails: str | None = None
55
73
 
56
74
 
57
75
  class UpdateCronJobParams(HookbaseModel):
@@ -60,12 +78,16 @@ class UpdateCronJobParams(HookbaseModel):
60
78
  url: str | None = None
61
79
  method: str | None = None
62
80
  headers: dict[str, str] | None = None
63
- body: str | None = None
64
- schedule: str | None = None
81
+ payload: str | None = None
82
+ cron_expression: str | None = None
65
83
  timezone: str | None = None
84
+ timeout_ms: int | None = None
66
85
  is_active: bool | None = None
67
86
  use_static_ip: bool | None = None
68
87
  group_id: str | None = None
88
+ notify_on_failure: bool | None = None
89
+ notify_on_success: bool | None = None
90
+ notify_emails: str | None = None
69
91
 
70
92
 
71
93
  class CronGroup(HookbaseModel):
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Literal
5
5
 
6
- from pydantic import field_validator, model_validator
6
+ from pydantic import Field, field_validator, model_validator
7
7
 
8
8
  from ._base import HookbaseModel
9
9
 
@@ -209,7 +209,10 @@ class UpdateDestinationParams(HookbaseModel):
209
209
 
210
210
  class TestResult(HookbaseModel):
211
211
  success: bool
212
- status_code: int | None = None
213
- duration: float | None = None
212
+ # The API answers with `status`/`latencyMs` (api/src/routes/destinations.ts), not the
213
+ # `statusCode`/`duration` that HookbaseModel's to_camel alias_generator would otherwise
214
+ # expect for these field names — override the alias explicitly rather than rely on it.
215
+ status_code: int | None = Field(default=None, alias="status")
216
+ duration: float | None = Field(default=None, alias="latencyMs")
214
217
  response_body: str | None = None
215
218
  error: str | None = None
@@ -13,6 +13,7 @@ from ..models.cron_jobs import (
13
13
  CreateCronJobParams,
14
14
  CronGroup,
15
15
  CronJob,
16
+ CronJobExecution,
16
17
  UpdateCronJobParams,
17
18
  )
18
19
  from ._base import AsyncResource, SyncResource, _to_body
@@ -49,6 +50,11 @@ class CronJobs(SyncResource):
49
50
  def delete(self, id: str) -> None:
50
51
  self._request("DELETE", f"/api/cron/{id}")
51
52
 
53
+ def trigger(self, id: str) -> CronJobExecution:
54
+ resp = self._request("POST", f"/api/cron/{id}/trigger")
55
+ data = resp.get("execution", resp)
56
+ return self._parse(CronJobExecution, data)
57
+
52
58
  def list_groups(self) -> list[CronGroup]:
53
59
  resp = self._request("GET", "/api/cron-groups")
54
60
  items = resp.get("groups", resp.get("data", []))
@@ -92,6 +98,11 @@ class AsyncCronJobs(AsyncResource):
92
98
  async def delete(self, id: str) -> None:
93
99
  await self._request("DELETE", f"/api/cron/{id}")
94
100
 
101
+ async def trigger(self, id: str) -> CronJobExecution:
102
+ resp = await self._request("POST", f"/api/cron/{id}/trigger")
103
+ data = resp.get("execution", resp)
104
+ return self._parse(CronJobExecution, data)
105
+
95
106
  async def list_groups(self) -> list[CronGroup]:
96
107
  resp = await self._request("GET", "/api/cron-groups")
97
108
  items = resp.get("groups", resp.get("data", []))
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ import respx
5
+
6
+ from hookbase import Hookbase
7
+ from hookbase.models import CronJob, CronJobExecution
8
+
9
+ from ..conftest import make_paginated_response
10
+
11
+ CRON_JOB_DATA = {
12
+ "id": "cron_1",
13
+ "organizationId": "org_1",
14
+ "groupId": None,
15
+ "name": "Nightly sync",
16
+ "description": None,
17
+ "cronExpression": "0 0 * * *",
18
+ "timezone": "UTC",
19
+ "url": "https://example.com/sync",
20
+ "method": "POST",
21
+ "headers": None,
22
+ "payload": None,
23
+ "timeoutMs": 30000,
24
+ "useStaticIp": False,
25
+ "isActive": True,
26
+ "notifyOnFailure": True,
27
+ "notifyOnSuccess": False,
28
+ "notifyEmails": None,
29
+ "consecutiveFailures": 0,
30
+ "lastRunAt": "2026-09-01T00:00:00Z",
31
+ "nextRunAt": "2026-09-02T00:00:00Z",
32
+ "createdAt": "2026-01-01T00:00:00Z",
33
+ "updatedAt": "2026-09-01T00:00:00Z",
34
+ }
35
+
36
+
37
+ @pytest.fixture
38
+ def mock_api():
39
+ with respx.mock(base_url="https://api.hookbase.app") as mock:
40
+ yield mock
41
+
42
+
43
+ @pytest.fixture
44
+ def client(mock_api):
45
+ c = Hookbase(api_key="whr_test")
46
+ yield c
47
+ c.close()
48
+
49
+
50
+ def test_list_cron_jobs_uses_real_field_names(mock_api, client):
51
+ # GET /api/cron sends cronExpression, timezone, nextRunAt, lastRunAt and
52
+ # consecutiveFailures -- not the old, fabricated "schedule"/"lastStatus" fields.
53
+ mock_api.get("/api/cron").respond(200, json=make_paginated_response(
54
+ [CRON_JOB_DATA], data_key="cronJobs", total=1,
55
+ ))
56
+ page = client.cron_jobs.list()
57
+ assert len(page) == 1
58
+ job = page.data[0]
59
+ assert isinstance(job, CronJob)
60
+ assert job.cron_expression == "0 0 * * *"
61
+ assert job.next_run_at == "2026-09-02T00:00:00Z"
62
+ assert job.last_run_at == "2026-09-01T00:00:00Z"
63
+ assert job.consecutive_failures == 0
64
+ assert job.payload is None
65
+ assert not hasattr(job, "last_status")
66
+ assert not hasattr(job, "schedule")
67
+
68
+
69
+ def test_get_cron_job_uses_real_field_names(mock_api, client):
70
+ mock_api.get("/api/cron/cron_1").respond(200, json={"cronJob": CRON_JOB_DATA})
71
+ job = client.cron_jobs.get("cron_1")
72
+ assert job.cron_expression == "0 0 * * *"
73
+ assert job.timeout_ms == 30000
74
+ assert job.notify_on_failure is True
75
+ assert job.notify_on_success is False
76
+ assert job.consecutive_failures == 0
77
+
78
+
79
+ def test_create_cron_job(mock_api, client):
80
+ mock_api.post("/api/cron").respond(200, json={"cronJob": CRON_JOB_DATA})
81
+ job = client.cron_jobs.create({
82
+ "name": "Nightly sync",
83
+ "url": "https://example.com/sync",
84
+ "cron_expression": "0 0 * * *",
85
+ "payload": '{"foo": "bar"}',
86
+ })
87
+ assert isinstance(job, CronJob)
88
+ assert job.cron_expression == "0 0 * * *"
89
+
90
+
91
+ def test_update_cron_job(mock_api, client):
92
+ mock_api.patch("/api/cron/cron_1").respond(200, json={"success": True})
93
+ client.cron_jobs.update("cron_1", {"cron_expression": "*/5 * * * *", "payload": "{}"})
94
+
95
+
96
+ def test_delete_cron_job(mock_api, client):
97
+ mock_api.delete("/api/cron/cron_1").respond(200, json={"success": True})
98
+ client.cron_jobs.delete("cron_1")
99
+
100
+
101
+ def test_trigger_cron_job_calls_trigger_endpoint(mock_api, client):
102
+ route = mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
103
+ "execution": {
104
+ "id": "exec_1",
105
+ "status": "success",
106
+ "responseStatus": 200,
107
+ "latencyMs": 142,
108
+ }
109
+ })
110
+ result = client.cron_jobs.trigger("cron_1")
111
+ assert route.called
112
+ assert isinstance(result, CronJobExecution)
113
+ assert result.id == "exec_1"
114
+ assert result.status == "success"
115
+ assert result.response_status == 200
116
+ assert result.latency_ms == 142
117
+
118
+
119
+ def test_trigger_cron_job_failed_execution(mock_api, client):
120
+ mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
121
+ "execution": {
122
+ "id": "exec_2",
123
+ "status": "failed",
124
+ "responseStatus": None,
125
+ "latencyMs": 30000,
126
+ }
127
+ })
128
+ result = client.cron_jobs.trigger("cron_1")
129
+ assert result.status == "failed"
130
+ assert result.response_status is None
131
+
132
+
133
+ async def test_async_list_cron_jobs(mock_api):
134
+ from hookbase import AsyncHookbase
135
+
136
+ mock_api.get("/api/cron").respond(200, json=make_paginated_response(
137
+ [CRON_JOB_DATA], data_key="cronJobs", total=1,
138
+ ))
139
+ client = AsyncHookbase(api_key="whr_test")
140
+ page = await client.cron_jobs.list()
141
+ assert page.data[0].cron_expression == "0 0 * * *"
142
+ await client.close()
143
+
144
+
145
+ async def test_async_trigger_cron_job(mock_api):
146
+ from hookbase import AsyncHookbase
147
+
148
+ mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
149
+ "execution": {
150
+ "id": "exec_1",
151
+ "status": "success",
152
+ "responseStatus": 200,
153
+ "latencyMs": 99,
154
+ }
155
+ })
156
+ client = AsyncHookbase(api_key="whr_test")
157
+ result = await client.cron_jobs.trigger("cron_1")
158
+ assert isinstance(result, CronJobExecution)
159
+ assert result.latency_ms == 99
160
+ await client.close()
@@ -272,10 +272,24 @@ def test_update_destination_params_throttle_serializes_camelcase():
272
272
 
273
273
 
274
274
  def test_test_destination(mock_api, client):
275
+ # The API answers with `status`/`latencyMs` (api/src/routes/destinations.ts), not
276
+ # `statusCode`/`duration` — the model aliases onto those field names explicitly.
275
277
  mock_api.post("/api/destinations/dst_1/test").respond(200, json={
276
- "success": True, "statusCode": 200, "duration": 150.5,
278
+ "success": True, "status": 200, "latencyMs": 150.5, "responseBody": "OK",
277
279
  })
278
280
  result = client.destinations.test("dst_1")
279
281
  assert isinstance(result, TestResult)
280
282
  assert result.success is True
281
283
  assert result.status_code == 200
284
+ assert result.duration == 150.5
285
+ assert result.response_body == "OK"
286
+
287
+
288
+ def test_test_destination_failure_surfaces_error(mock_api, client):
289
+ mock_api.post("/api/destinations/dst_1/test").respond(200, json={
290
+ "success": False, "error": "Network error",
291
+ })
292
+ result = client.destinations.test("dst_1")
293
+ assert result.success is False
294
+ assert result.error == "Network error"
295
+ assert result.status_code is None
@@ -30,16 +30,17 @@ def client(mock_api):
30
30
 
31
31
  def test_401_raises_authentication_error(mock_api, client):
32
32
  mock_api.get("/api/sources/src_1").respond(
33
- 401, json={"error": {"message": "Invalid API key"}}
33
+ 401, json={"error": "Invalid API key", "code": "authentication_error"}
34
34
  )
35
35
  with pytest.raises(AuthenticationError) as exc_info:
36
36
  client.sources.get("src_1")
37
37
  assert exc_info.value.status_code == 401
38
+ assert str(exc_info.value) == "Invalid API key"
38
39
 
39
40
 
40
41
  def test_403_raises_forbidden_error(mock_api, client):
41
42
  mock_api.get("/api/sources/src_1").respond(
42
- 403, json={"error": {"message": "Forbidden"}}
43
+ 403, json={"error": "Forbidden", "code": "forbidden"}
43
44
  )
44
45
  with pytest.raises(ForbiddenError):
45
46
  client.sources.get("src_1")
@@ -47,7 +48,7 @@ def test_403_raises_forbidden_error(mock_api, client):
47
48
 
48
49
  def test_404_raises_not_found_error(mock_api, client):
49
50
  mock_api.get("/api/sources/src_1").respond(
50
- 404, json={"error": {"message": "Not found"}}
51
+ 404, json={"error": "Not found", "code": "not_found"}
51
52
  )
52
53
  with pytest.raises(NotFoundError):
53
54
  client.sources.get("src_1")
@@ -56,17 +57,24 @@ def test_404_raises_not_found_error(mock_api, client):
56
57
  def test_400_raises_validation_error(mock_api, client):
57
58
  mock_api.post("/api/sources").respond(
58
59
  400,
59
- json={"error": {"message": "Name is required",
60
- "validationErrors": {"name": ["required"]}}},
60
+ json={
61
+ "error": "Name is required",
62
+ "code": "validation_error",
63
+ "details": {
64
+ "fieldErrors": {"name": ["required"]},
65
+ "formErrors": [],
66
+ },
67
+ },
61
68
  )
62
69
  with pytest.raises(ValidationError) as exc_info:
63
70
  client.sources.create({"name": ""})
64
71
  assert exc_info.value.validation_errors == {"name": ["required"]}
72
+ assert str(exc_info.value) == "Name is required"
65
73
 
66
74
 
67
75
  def test_409_raises_conflict_error(mock_api, client):
68
76
  mock_api.post("/api/sources").respond(
69
- 409, json={"error": {"message": "Slug already exists"}}
77
+ 409, json={"error": "Slug already exists", "code": "conflict"}
70
78
  )
71
79
  with pytest.raises(ConflictError):
72
80
  client.sources.create({"name": "test", "slug": "existing"})
@@ -75,7 +83,7 @@ def test_409_raises_conflict_error(mock_api, client):
75
83
  def test_429_raises_rate_limit_error(mock_api, client):
76
84
  mock_api.get("/api/sources/src_1").respond(
77
85
  429,
78
- json={"error": {"message": "Too many requests"}},
86
+ json={"error": "Too many requests", "code": "rate_limit_exceeded"},
79
87
  headers={"retry-after": "30"},
80
88
  )
81
89
  with pytest.raises(RateLimitError) as exc_info:
@@ -85,16 +93,45 @@ def test_429_raises_rate_limit_error(mock_api, client):
85
93
 
86
94
  def test_500_raises_api_error(mock_api, client):
87
95
  mock_api.get("/api/sources/src_1").respond(
88
- 500, json={"error": {"message": "Internal error"}}
96
+ 500, json={"error": "Internal error", "code": "internal_error"}
89
97
  )
90
98
  with pytest.raises(APIError) as exc_info:
91
99
  client.sources.get("src_1")
92
100
  assert exc_info.value.status_code == 500
101
+ # The real API's `code` is a body-level sibling of `error`, not nested inside it —
102
+ # make sure it's actually read rather than falling back to the generic default.
103
+ assert exc_info.value.code == "internal_error"
104
+
105
+
106
+ def test_error_code_falls_back_to_default_when_absent(mock_api, client):
107
+ mock_api.get("/api/sources/src_1").respond(500, json={"error": "Internal error"})
108
+ with pytest.raises(APIError) as exc_info:
109
+ client.sources.get("src_1")
110
+ assert exc_info.value.code == "unknown_error"
93
111
 
94
112
 
95
113
  def test_error_from_response_string_error():
96
- err = APIError.from_response(500, {"error": "Server error"})
114
+ err = APIError.from_response(500, {"error": "Server error", "code": "server_error"})
97
115
  assert str(err) == "Server error"
116
+ assert err.code == "server_error"
117
+
118
+
119
+ def test_error_from_response_string_error_default_code():
120
+ err = APIError.from_response(500, {"error": "Server error"})
121
+ assert err.code == "unknown_error"
122
+
123
+
124
+ def test_error_from_response_validation_details():
125
+ err = APIError.from_response(
126
+ 400,
127
+ {
128
+ "error": "Name is required",
129
+ "code": "validation_error",
130
+ "details": {"fieldErrors": {"name": ["required"]}},
131
+ },
132
+ )
133
+ assert isinstance(err, ValidationError)
134
+ assert err.validation_errors == {"name": ["required"]}
98
135
 
99
136
 
100
137
  def test_error_from_response_dict_error():
@@ -102,3 +139,4 @@ def test_error_from_response_dict_error():
102
139
  500, {"error": {"message": "Detailed error", "code": "server_error"}},
103
140
  )
104
141
  assert str(err) == "Detailed error"
142
+ assert err.code == "server_error"
@@ -1 +0,0 @@
1
- __version__ = "2.6.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes