Skip to content

Commit 711a04a

Browse files
authored
test(integration): add scenario-driven integration tests (#51)
* test(integration): add scenario-driven integration tests against prod * ci: run integration tests on pull requests too * ci: fetch scenarios via authenticated GitHub API (www repo is private) * test(integration): align secrets name casing and ResultInfo field * test(integration): drop indexes_lifecycle (now optional_for python)
1 parent f858454 commit 711a04a

14 files changed

Lines changed: 687 additions & 0 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Integration Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
jobs:
11+
# Parity check runs on every PR and push: confirms every scenario in
12+
# www.hotdata.dev/api/test-scenarios.yaml has a matching test file in this
13+
# repo. www.hotdata.dev is private, so we fetch via the GitHub App token —
14+
# same pattern as regenerate.yml.
15+
scenario-parity:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Generate GitHub App token
19+
id: app-token
20+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3
21+
with:
22+
app-id: 3060111
23+
private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
24+
owner: hotdata-dev
25+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
26+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
27+
with:
28+
python-version: '3.12'
29+
- name: Install PyYAML
30+
run: pip install --quiet pyyaml
31+
- name: Fetch scenarios manifest
32+
env:
33+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
34+
run: |
35+
curl -sS -f -L \
36+
-H "Accept: application/vnd.github.v3.raw" \
37+
-H "Authorization: Bearer $GH_TOKEN" \
38+
https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \
39+
-o test-scenarios.yaml
40+
- name: Check parity
41+
run: |
42+
python3 - <<'PY'
43+
import sys, pathlib, yaml
44+
scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"]
45+
missing = []
46+
for s in scenarios:
47+
if "python" in (s.get("optional_for") or []):
48+
continue
49+
expected = pathlib.Path("tests/integration") / f"test_{s['name']}.py"
50+
if not expected.exists():
51+
missing.append(str(expected))
52+
if missing:
53+
print(f"::error::sdk-python is missing tests for {len(missing)} scenarios:")
54+
for m in missing:
55+
print(f" - {m}")
56+
sys.exit(1)
57+
print(f"All {len(scenarios)} scenarios have corresponding test files.")
58+
PY
59+
60+
# Integration tests run against production. Skipped automatically by the
61+
# conftest if HOTDATA_SDK_TEST_API_KEY / HOTDATA_SDK_TEST_WORKSPACE_ID aren't
62+
# set (e.g. PRs from forks where secrets aren't injected).
63+
integration:
64+
runs-on: ubuntu-latest
65+
steps:
66+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
67+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
68+
with:
69+
python-version: '3.12'
70+
- name: Install package and test deps
71+
run: |
72+
pip install --quiet -r requirements.txt -r test-requirements.txt
73+
pip install --quiet -e .
74+
- name: Run integration tests
75+
env:
76+
HOTDATA_SDK_TEST_API_URL: ${{ vars.HOTDATA_SDK_TEST_API_URL }}
77+
HOTDATA_SDK_TEST_API_KEY: ${{ secrets.HOTDATA_SDK_TEST_API_KEY }}
78+
HOTDATA_SDK_TEST_WORKSPACE_ID: ${{ vars.HOTDATA_SDK_TEST_WORKSPACE_ID }}
79+
HOTDATA_SDK_TEST_CONNECTION_ID: ${{ vars.HOTDATA_SDK_TEST_CONNECTION_ID }}
80+
run: pytest tests/integration -v

.github/workflows/regenerate.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,35 @@ jobs:
110110
# cd away from the source tree so the import resolves against the installed wheel.
111111
cd /tmp && python -c "import hotdata; print(hotdata.__version__)"
112112
113+
- name: Check integration test scenario parity
114+
env:
115+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
116+
run: |
117+
curl -sS -f -L \
118+
-H "Accept: application/vnd.github.v3.raw" \
119+
-H "Authorization: Bearer $GH_TOKEN" \
120+
https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \
121+
-o test-scenarios.yaml
122+
pip install --quiet pyyaml
123+
python3 - <<'PY'
124+
import sys, pathlib, yaml
125+
scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"]
126+
missing = []
127+
for s in scenarios:
128+
if "python" in (s.get("optional_for") or []):
129+
continue
130+
expected = pathlib.Path("tests/integration") / f"test_{s['name']}.py"
131+
if not expected.exists():
132+
missing.append(str(expected))
133+
if missing:
134+
print(f"::warning::sdk-python is missing tests for {len(missing)} scenarios after regen:")
135+
for m in missing:
136+
print(f" - {m}")
137+
else:
138+
print(f"All {len(scenarios)} scenarios have corresponding test files.")
139+
PY
140+
rm -f test-scenarios.yaml
141+
113142
- name: Create PR
114143
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
115144
with:

tests/__init__.py

Whitespace-only changes.

tests/integration/__init__.py

Whitespace-only changes.

tests/integration/conftest.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Shared fixtures for SDK integration tests.
2+
3+
Tests run against production. See www.hotdata.dev/api/README.md for the
4+
contract — env vars, naming conventions, blast-radius rules.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import uuid
11+
from dataclasses import dataclass
12+
from typing import Iterator
13+
14+
import pytest
15+
16+
from hotdata import ApiClient, Configuration
17+
from hotdata.api.connections_api import ConnectionsApi
18+
from hotdata.api.datasets_api import DatasetsApi
19+
from hotdata.api.indexes_api import IndexesApi
20+
from hotdata.api.saved_queries_api import SavedQueriesApi
21+
from hotdata.api.secrets_api import SecretsApi
22+
from hotdata.api.workspaces_api import WorkspacesApi
23+
24+
25+
REQUIRED_ENV = ("HOTDATA_SDK_TEST_API_KEY", "HOTDATA_SDK_TEST_WORKSPACE_ID")
26+
DEFAULT_API_URL = "https://api.hotdata.dev"
27+
28+
29+
@dataclass(frozen=True)
30+
class TestEnv:
31+
api_url: str
32+
api_key: str
33+
workspace_id: str
34+
connection_id: str | None
35+
36+
37+
def _load_env() -> TestEnv:
38+
missing = [name for name in REQUIRED_ENV if not os.environ.get(name)]
39+
if missing:
40+
pytest.skip(
41+
"SDK integration tests require env vars: " + ", ".join(missing)
42+
)
43+
# GitHub Actions sets `env:` keys even when the underlying secret/var is
44+
# unset, producing empty strings rather than absent keys. Use `or` to fall
45+
# back to the default for url and to None for the optional connection id.
46+
return TestEnv(
47+
api_url=os.environ.get("HOTDATA_SDK_TEST_API_URL") or DEFAULT_API_URL,
48+
api_key=os.environ["HOTDATA_SDK_TEST_API_KEY"],
49+
workspace_id=os.environ["HOTDATA_SDK_TEST_WORKSPACE_ID"],
50+
connection_id=os.environ.get("HOTDATA_SDK_TEST_CONNECTION_ID") or None,
51+
)
52+
53+
54+
@pytest.fixture(scope="session")
55+
def env() -> TestEnv:
56+
return _load_env()
57+
58+
59+
@pytest.fixture(scope="session")
60+
def api_client(env: TestEnv) -> Iterator[ApiClient]:
61+
config = Configuration(
62+
host=env.api_url,
63+
api_key=env.api_key,
64+
workspace_id=env.workspace_id,
65+
)
66+
with ApiClient(config) as client:
67+
yield client
68+
69+
70+
@pytest.fixture(scope="session")
71+
def workspace_id(env: TestEnv) -> str:
72+
return env.workspace_id
73+
74+
75+
@pytest.fixture(scope="session")
76+
def connection_id(env: TestEnv) -> str:
77+
if not env.connection_id:
78+
pytest.skip("HOTDATA_SDK_TEST_CONNECTION_ID required for this scenario")
79+
return env.connection_id
80+
81+
82+
@pytest.fixture
83+
def sdkci_name() -> "callable[[str], str]":
84+
"""Returns `sdkci-<scenario>-<uuid8>` so orphans are identifiable.
85+
86+
See api/README.md — every test-created resource must use this prefix.
87+
"""
88+
89+
def _make(scenario: str) -> str:
90+
return f"sdkci-{scenario}-{uuid.uuid4().hex[:8]}"
91+
92+
return _make
93+
94+
95+
# Per-API client fixtures keep tests one-liner short and avoid every test
96+
# instantiating its own *Api(api_client).
97+
@pytest.fixture
98+
def datasets_api(api_client: ApiClient) -> DatasetsApi:
99+
return DatasetsApi(api_client)
100+
101+
102+
@pytest.fixture
103+
def workspaces_api(api_client: ApiClient) -> WorkspacesApi:
104+
return WorkspacesApi(api_client)
105+
106+
107+
@pytest.fixture
108+
def connections_api(api_client: ApiClient) -> ConnectionsApi:
109+
return ConnectionsApi(api_client)
110+
111+
112+
@pytest.fixture
113+
def indexes_api(api_client: ApiClient) -> IndexesApi:
114+
return IndexesApi(api_client)
115+
116+
117+
@pytest.fixture
118+
def saved_queries_api(api_client: ApiClient) -> SavedQueriesApi:
119+
return SavedQueriesApi(api_client)
120+
121+
122+
@pytest.fixture
123+
def secrets_api(api_client: ApiClient) -> SecretsApi:
124+
return SecretsApi(api_client)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Scenario: auth_missing_token_401.
2+
3+
Calls without a bearer token return 401 with the documented ApiErrorResponse
4+
shape. Uses an unauthenticated client built locally — does not touch the
5+
session-scoped api_client.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
12+
from hotdata import ApiClient, Configuration
13+
from hotdata.api.workspaces_api import WorkspacesApi
14+
from hotdata.exceptions import ApiException
15+
16+
17+
def test_auth_missing_token_401(env) -> None:
18+
config = Configuration(host=env.api_url) # no api_key, no workspace_id
19+
with ApiClient(config) as client:
20+
api = WorkspacesApi(client)
21+
with pytest.raises(ApiException) as excinfo:
22+
api.list_workspaces()
23+
assert excinfo.value.status == 401, (
24+
f"expected 401 without bearer token, got {excinfo.value.status}"
25+
)
26+
body = excinfo.value.body or ""
27+
assert body, "expected non-empty error body on 401"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Scenario: auth_unknown_workspace.
2+
3+
A valid bearer token combined with a fabricated workspace id (random UUID) must
4+
return a 4xx error and never leak data from another workspace. Server may
5+
respond 403 (forbidden) or 404 (not found) — both are acceptable.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import uuid
11+
12+
import pytest
13+
14+
from hotdata import ApiClient, Configuration
15+
from hotdata.api.datasets_api import DatasetsApi
16+
from hotdata.exceptions import ApiException
17+
18+
19+
def test_auth_unknown_workspace(env) -> None:
20+
fake_workspace = f"ws_{uuid.uuid4().hex}"
21+
config = Configuration(
22+
host=env.api_url,
23+
api_key=env.api_key,
24+
workspace_id=fake_workspace,
25+
)
26+
with ApiClient(config) as client:
27+
api = DatasetsApi(client)
28+
with pytest.raises(ApiException) as excinfo:
29+
api.list_datasets()
30+
assert excinfo.value.status in (403, 404), (
31+
f"expected 403/404 for fabricated workspace, got {excinfo.value.status}"
32+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Scenario: connections_read.
2+
3+
Read-only lifecycle ops on the seeded connection — get, list, health check,
4+
and cache purge. Does not create or delete connections in prod (would require
5+
real datastore credentials in CI secrets).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from hotdata.api.connections_api import ConnectionsApi
11+
12+
13+
def test_connections_read(connections_api: ConnectionsApi, connection_id: str) -> None:
14+
detail = connections_api.get_connection(connection_id)
15+
assert detail.id == connection_id
16+
assert detail.source_type
17+
assert detail.name
18+
19+
listing = connections_api.list_connections()
20+
assert any(c.id == connection_id for c in listing.connections), (
21+
f"seeded connection {connection_id} not in list_connections"
22+
)
23+
24+
health = connections_api.check_connection_health(connection_id)
25+
assert health.connection_id == connection_id
26+
assert health.healthy, f"seeded connection unhealthy: {health.error}"
27+
28+
# purge_connection_cache returns None on success.
29+
connections_api.purge_connection_cache(connection_id)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Scenario: dataset_versioning.
2+
3+
Create a dataset, exercise list_dataset_versions, pin to a specific version,
4+
then unpin. Confirms the versioning surface is reachable and consistent.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from hotdata.api.datasets_api import DatasetsApi
10+
from hotdata.exceptions import ApiException
11+
from hotdata.models.create_dataset_request import CreateDatasetRequest
12+
from hotdata.models.dataset_source import DatasetSource
13+
from hotdata.models.inline_data import InlineData
14+
from hotdata.models.inline_dataset_source import InlineDatasetSource
15+
from hotdata.models.update_dataset_request import UpdateDatasetRequest
16+
17+
18+
def _inline_csv_source() -> DatasetSource:
19+
return DatasetSource(
20+
InlineDatasetSource(
21+
inline=InlineData(content="a,b\n1,2\n3,4\n", format="csv")
22+
)
23+
)
24+
25+
26+
def test_dataset_versioning(datasets_api: DatasetsApi, sdkci_name) -> None:
27+
label = sdkci_name("dataset-versioning")
28+
created_id: str | None = None
29+
30+
try:
31+
created = datasets_api.create_dataset(
32+
CreateDatasetRequest(label=label, source=_inline_csv_source())
33+
)
34+
created_id = created.id
35+
36+
versions = datasets_api.list_dataset_versions(created.id)
37+
assert versions.dataset_id == created.id
38+
assert versions.count >= 1
39+
assert any(v.version == 1 for v in versions.versions), (
40+
f"expected version 1 in {[v.version for v in versions.versions]}"
41+
)
42+
43+
pinned = datasets_api.update_dataset(
44+
created.id, UpdateDatasetRequest(pinned_version=1)
45+
)
46+
assert pinned.pinned_version == 1
47+
assert pinned.latest_version >= 1
48+
49+
fetched = datasets_api.get_dataset(created.id)
50+
assert fetched.pinned_version == 1
51+
finally:
52+
if created_id is not None:
53+
try:
54+
datasets_api.delete_dataset(created_id)
55+
except ApiException:
56+
pass

0 commit comments

Comments
 (0)