Skip to content

Commit ef8de19

Browse files
authored
Merge pull request #194 from dbt-labs/fix/single-tenant-base-url
2 parents d22abfd + 0475978 commit ef8de19

File tree

2 files changed

+159
-2
lines changed

2 files changed

+159
-2
lines changed

src/dbt_jobs_as_code/client/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __init__(
4242
int, Dict[str, CustomEnvironmentVariablePayload]
4343
] = {}
4444

45-
self.base_url = base_url
45+
self.base_url = base_url.rstrip("/")
4646
self._headers = {
4747
"Authorization": f"Bearer {self._api_key}",
4848
"Content-Type": "application/json",
@@ -250,7 +250,7 @@ def _make_request(self, parameters: dict[str, Any]):
250250
"401 Unauthorized -- Check your API key and dbt Cloud parameters"
251251
)
252252

253-
return None
253+
raise DBTCloudException(f"Error fetching jobs (HTTP {response.status_code})")
254254

255255
return response.json()
256256

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
5+
from dbt_jobs_as_code.client import DBTCloud, DBTCloudException
6+
7+
8+
@pytest.fixture
9+
def job_api_response():
10+
"""Minimal valid job response from dbt Cloud API."""
11+
return {
12+
"data": {
13+
"id": 123,
14+
"account_id": 1,
15+
"project_id": 1,
16+
"environment_id": 1,
17+
"name": "Test Job",
18+
"execute_steps": ["dbt run"],
19+
"settings": {"threads": 4, "target_name": "prod"},
20+
"triggers": {"github_webhook": False, "schedule": True},
21+
"schedule": {"cron": "0 0 * * *"},
22+
"state": 1,
23+
"generate_docs": False,
24+
"run_generate_sources": False,
25+
}
26+
}
27+
28+
29+
@pytest.fixture
30+
def jobs_list_api_response(job_api_response):
31+
"""Minimal valid jobs list response from dbt Cloud API."""
32+
return {
33+
"data": [job_api_response["data"]],
34+
"extra": {
35+
"filters": {"limit": 100, "offset": 0},
36+
"pagination": {"total_count": 1},
37+
},
38+
}
39+
40+
41+
@pytest.mark.parametrize(
42+
"base_url",
43+
[
44+
"https://cloud.getdbt.com/",
45+
"https://custom.us1.dbt.com/",
46+
"https://cloud.getdbt.com///",
47+
],
48+
)
49+
class TestTrailingSlashInBaseUrl:
50+
def test_get_job_url_has_no_double_slash(self, base_url, job_api_response):
51+
client = DBTCloud(account_id=1, api_key="test", base_url=base_url)
52+
mock_response = MagicMock()
53+
mock_response.status_code = 200
54+
mock_response.json.return_value = job_api_response
55+
client._session.get = MagicMock(return_value=mock_response)
56+
57+
client.get_job(job_id=456)
58+
59+
called_url = client._session.get.call_args[1].get(
60+
"url",
61+
client._session.get.call_args[0][0] if client._session.get.call_args[0] else None,
62+
)
63+
assert "//" not in called_url.split("://")[1], f"Double slash in URL: {called_url}"
64+
65+
def test_get_jobs_url_has_no_double_slash(self, base_url, jobs_list_api_response):
66+
client = DBTCloud(account_id=1, api_key="test", base_url=base_url)
67+
mock_response = MagicMock()
68+
mock_response.status_code = 200
69+
mock_response.json.return_value = jobs_list_api_response
70+
client._session.get = MagicMock(return_value=mock_response)
71+
72+
client.get_jobs(project_ids=[1])
73+
74+
called_url = client._session.get.call_args[1].get(
75+
"url",
76+
client._session.get.call_args[0][0] if client._session.get.call_args[0] else None,
77+
)
78+
assert "//" not in called_url.split("://")[1], f"Double slash in URL: {called_url}"
79+
80+
def test_update_job_url_has_no_double_slash(self, base_url, job_api_response):
81+
client = DBTCloud(account_id=1, api_key="test", base_url=base_url)
82+
mock_response = MagicMock()
83+
mock_response.status_code = 200
84+
mock_response.json.return_value = job_api_response
85+
client._session.post = MagicMock(return_value=mock_response)
86+
87+
from dbt_jobs_as_code.schemas.job import JobDefinition
88+
89+
job = JobDefinition(**job_api_response["data"])
90+
job.identifier = "test_job"
91+
client.update_job(job=job)
92+
93+
called_url = client._session.post.call_args[1].get(
94+
"url",
95+
client._session.post.call_args[0][0] if client._session.post.call_args[0] else None,
96+
)
97+
assert "//" not in called_url.split("://")[1], f"Double slash in URL: {called_url}"
98+
99+
def test_delete_job_url_has_no_double_slash(self, base_url, job_api_response):
100+
client = DBTCloud(account_id=1, api_key="test", base_url=base_url)
101+
mock_response = MagicMock()
102+
mock_response.status_code = 200
103+
client._session.delete = MagicMock(return_value=mock_response)
104+
105+
from dbt_jobs_as_code.schemas.job import JobDefinition
106+
107+
job = JobDefinition(**job_api_response["data"])
108+
client.delete_job(job=job)
109+
110+
called_url = client._session.delete.call_args[1].get(
111+
"url",
112+
client._session.delete.call_args[0][0]
113+
if client._session.delete.call_args[0]
114+
else None,
115+
)
116+
assert "//" not in called_url.split("://")[1], f"Double slash in URL: {called_url}"
117+
118+
def test_create_job_url_has_no_double_slash(self, base_url, job_api_response):
119+
client = DBTCloud(account_id=1, api_key="test", base_url=base_url)
120+
mock_response = MagicMock()
121+
mock_response.status_code = 200
122+
mock_response.json.return_value = job_api_response
123+
client._session.post = MagicMock(return_value=mock_response)
124+
125+
from dbt_jobs_as_code.schemas.job import JobDefinition
126+
127+
job = JobDefinition(**job_api_response["data"])
128+
job.identifier = "test_job"
129+
client.create_job(job=job)
130+
131+
called_url = client._session.post.call_args[1].get(
132+
"url",
133+
client._session.post.call_args[0][0] if client._session.post.call_args[0] else None,
134+
)
135+
assert "//" not in called_url.split("://")[1], f"Double slash in URL: {called_url}"
136+
137+
138+
class TestGetJobsErrorHandling:
139+
"""get_jobs silently swallows non-401 errors, returning an empty list.
140+
141+
If a malformed URL (e.g. double-slash from trailing slash in base_url)
142+
causes the API to return 404, the tool silently proceeds with zero cloud
143+
jobs, treating every YAML job as new (CREATE instead of UPDATE).
144+
"""
145+
146+
@pytest.mark.parametrize("status_code", [401, 403, 404, 500, 502])
147+
def test_get_jobs_raises_on_api_error(self, status_code):
148+
client = DBTCloud(account_id=1, api_key="test", base_url="https://cloud.getdbt.com")
149+
mock_response = MagicMock()
150+
mock_response.status_code = status_code
151+
mock_response.json.return_value = {
152+
"status": {"code": status_code, "user_message": "error"}
153+
}
154+
client._session.get = MagicMock(return_value=mock_response)
155+
156+
with pytest.raises(DBTCloudException):
157+
client.get_jobs(project_ids=[1])

0 commit comments

Comments
 (0)