Skip to content

Commit 5ada192

Browse files
authored
add end-to-end tests (#98)
1 parent 47b9060 commit 5ada192

9 files changed

Lines changed: 758 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ jobs:
1313
uses: astral-sh/setup-uv@v6
1414
with:
1515
version: "0.8.2"
16+
- name: Sync dependencies
17+
run: |
18+
uv sync --all-groups
1619
- name: Static code check
1720
run: uv run poe ci_checker
1821
- name: Check docs build
1922
working-directory: ./docs
2023
run: |
21-
uv sync --group docs
2224
make html
2325
- name: Check PR title style
2426
uses: actions/github-script@v7

pyproject.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ dependencies = [
1414

1515
[dependency-groups]
1616
dev = ["mypy>=1.14.1", "poethepoet>=0.36.0", "ruff>=0.9.1"]
17+
test = [
18+
"pytest>=8.0.0",
19+
"pytest-asyncio>=0.23.0",
20+
"pytest-timeout>=2.3.0",
21+
"pytest-xdist>=3.5.0",
22+
]
1723
docs = [
1824
"enum-tools[sphinx]>=0.12.0",
1925
"furo>=2024.8.6",
@@ -27,7 +33,7 @@ requires = ["hatchling"]
2733
build-backend = "hatchling.build"
2834

2935
[tool.mypy]
30-
files = ["src/"]
36+
files = ["src/", "tests/", "examples/"]
3137

3238
[tool.ruff]
3339
exclude = [
@@ -51,3 +57,7 @@ ci_linter = "uv run ruff check"
5157
ci_formatter = "uv run ruff format --check"
5258
checker = ["linter", "formatter", "type_checker"]
5359
ci_checker = ["ci_linter", "ci_formatter", "type_checker"]
60+
e2e_tests = "uv run pytest tests/ -v -s"
61+
e2e_account_tests = "uv run pytest tests/ -v -s -m account"
62+
e2e_basin_tests = "uv run pytest tests/ -v -s -m basin"
63+
e2e_stream_tests = "uv run pytest tests/ -v -s -m stream"

pytest.ini

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[pytest]
2+
3+
testpaths = tests
4+
python_files = test_*.py
5+
python_classes = Test*
6+
python_functions = test_*
7+
8+
asyncio_mode = auto
9+
asyncio_default_fixture_loop_scope = session
10+
asyncio_default_test_loop_scope = session
11+
12+
timeout = 300
13+
timeout_method = thread
14+
15+
markers =
16+
account: tests for account operations
17+
basin: tests for basin operations
18+
stream: tests for stream operations

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import os
2+
import uuid
3+
from typing import AsyncGenerator, Final
4+
5+
import pytest
6+
import pytest_asyncio
7+
8+
from streamstore import S2, Basin, Stream
9+
10+
pytest_plugins = ["pytest_asyncio"]
11+
12+
13+
BASIN_PREFIX: Final[str] = "test-py-sdk"
14+
15+
16+
@pytest.fixture(scope="session")
17+
def access_token() -> str:
18+
token = os.getenv("S2_ACCESS_TOKEN")
19+
if not token:
20+
pytest.fail("S2_ACCESS_TOKEN environment variable not set")
21+
return token
22+
23+
24+
@pytest.fixture(scope="session")
25+
def basin_prefix() -> str:
26+
return BASIN_PREFIX
27+
28+
29+
@pytest_asyncio.fixture(scope="session")
30+
async def s2(access_token: str) -> AsyncGenerator[S2, None]:
31+
async with S2(access_token=access_token) as client:
32+
yield client
33+
34+
35+
@pytest.fixture
36+
def basin_name() -> str:
37+
return _basin_name()
38+
39+
40+
@pytest.fixture
41+
def basin_names() -> list[str]:
42+
return [_basin_name() for _ in range(3)]
43+
44+
45+
@pytest.fixture
46+
def stream_name() -> str:
47+
return _stream_name()
48+
49+
50+
@pytest.fixture
51+
def stream_names() -> list[str]:
52+
return [_stream_name() for _ in range(3)]
53+
54+
55+
@pytest.fixture
56+
def token_id() -> str:
57+
return f"token-{uuid.uuid4().hex[:8]}"
58+
59+
60+
@pytest_asyncio.fixture
61+
async def basin(s2: S2, basin_name: str) -> AsyncGenerator[Basin, None]:
62+
await s2.create_basin(
63+
name=basin_name,
64+
)
65+
66+
try:
67+
yield s2.basin(basin_name)
68+
finally:
69+
await s2.delete_basin(basin_name)
70+
71+
72+
@pytest_asyncio.fixture(scope="class")
73+
async def shared_basin(s2: S2) -> AsyncGenerator[Basin, None]:
74+
basin_name = _basin_name()
75+
await s2.create_basin(name=basin_name)
76+
77+
try:
78+
yield s2.basin(basin_name)
79+
finally:
80+
await s2.delete_basin(basin_name)
81+
82+
83+
@pytest_asyncio.fixture
84+
async def stream(shared_basin: Basin, stream_name: str) -> AsyncGenerator[Stream, None]:
85+
basin = shared_basin
86+
await basin.create_stream(name=stream_name)
87+
88+
try:
89+
yield basin.stream(stream_name)
90+
finally:
91+
await basin.delete_stream(stream_name)
92+
93+
94+
def _basin_name() -> str:
95+
return f"{BASIN_PREFIX}-{uuid.uuid4().hex[:8]}"
96+
97+
98+
def _stream_name() -> str:
99+
return f"stream-{uuid.uuid4().hex[:8]}"

tests/test_account_ops.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import time
2+
3+
import pytest
4+
5+
from streamstore import S2, Basin
6+
from streamstore.schemas import (
7+
AccessTokenScope,
8+
BasinConfig,
9+
BasinScope,
10+
BasinState,
11+
Operation,
12+
OperationGroupPermissions,
13+
Permission,
14+
ResourceMatchOp,
15+
ResourceMatchRule,
16+
StorageClass,
17+
StreamConfig,
18+
Timestamping,
19+
TimestampingMode,
20+
)
21+
22+
23+
@pytest.mark.account
24+
class TestAccountOperations:
25+
async def test_create_basin(self, s2: S2, basin_name: str):
26+
basin_info = await s2.create_basin(name=basin_name)
27+
28+
try:
29+
assert basin_info.name == basin_name
30+
assert basin_info.scope == BasinScope.AWS_US_EAST_1
31+
assert basin_info.state in (BasinState.ACTIVE, BasinState.CREATING)
32+
finally:
33+
await s2.delete_basin(basin_name)
34+
35+
async def test_create_basin_with_config(self, s2: S2, basin_name: str):
36+
config = BasinConfig(
37+
default_stream_config=StreamConfig(
38+
storage_class=StorageClass.STANDARD,
39+
retention_age=86400 * 7,
40+
timestamping=Timestamping(
41+
mode=TimestampingMode.CLIENT_REQUIRE,
42+
uncapped=True,
43+
),
44+
delete_on_empty_min_age=3600,
45+
),
46+
create_stream_on_append=True,
47+
)
48+
49+
basin_info = await s2.create_basin(name=basin_name, config=config)
50+
51+
try:
52+
assert basin_info.name == basin_name
53+
54+
retrieved_config = await s2.get_basin_config(basin_name)
55+
assert config == retrieved_config
56+
finally:
57+
await s2.delete_basin(basin_name)
58+
59+
async def test_reconfigure_basin(self, s2: S2, basin: Basin):
60+
config = BasinConfig(
61+
default_stream_config=StreamConfig(
62+
storage_class=StorageClass.STANDARD,
63+
retention_age=3600,
64+
),
65+
create_stream_on_append=True,
66+
)
67+
68+
updated_config = await s2.reconfigure_basin(basin.name, config)
69+
70+
assert config.default_stream_config is not None
71+
assert (
72+
updated_config.default_stream_config.storage_class
73+
== config.default_stream_config.storage_class
74+
)
75+
assert (
76+
updated_config.default_stream_config.retention_age
77+
== config.default_stream_config.retention_age
78+
)
79+
assert updated_config.create_stream_on_append == config.create_stream_on_append
80+
81+
assert (
82+
updated_config.default_stream_config.timestamping.mode
83+
== TimestampingMode.UNSPECIFIED
84+
)
85+
86+
assert updated_config.default_stream_config.delete_on_empty_min_age == 0
87+
88+
async def test_list_basins(self, s2: S2, basin_names: list[str]):
89+
basin_infos = []
90+
try:
91+
for basin_name in basin_names:
92+
stream_info = await s2.create_basin(name=basin_name)
93+
basin_infos.append(stream_info)
94+
95+
page = await s2.list_basins()
96+
97+
retrieved_basin_names = [b.name for b in page.items]
98+
assert set(basin_names).issubset(retrieved_basin_names)
99+
100+
finally:
101+
for basin_info in basin_infos:
102+
await s2.delete_basin(basin_info.name)
103+
104+
async def test_list_basins_with_limit(self, s2: S2, basin_names: list[str]):
105+
basin_infos = []
106+
try:
107+
for basin_name in basin_names:
108+
stream_info = await s2.create_basin(name=basin_name)
109+
basin_infos.append(stream_info)
110+
111+
page = await s2.list_basins(limit=1)
112+
113+
assert len(page.items) == 1
114+
115+
finally:
116+
for basin_info in basin_infos:
117+
await s2.delete_basin(basin_info.name)
118+
119+
async def test_list_basins_with_prefix(self, s2: S2, basin_name: str):
120+
await s2.create_basin(name=basin_name)
121+
122+
try:
123+
prefix = basin_name[:5]
124+
page = await s2.list_basins(prefix=prefix)
125+
126+
basin_names = [b.name for b in page.items]
127+
assert basin_name in basin_names
128+
129+
for name in basin_names:
130+
assert name.startswith(prefix)
131+
132+
finally:
133+
await s2.delete_basin(basin_name)
134+
135+
async def test_issue_access_token(self, s2: S2, token_id: str, basin_prefix: str):
136+
scope = AccessTokenScope(
137+
basins=ResourceMatchRule(
138+
match_op=ResourceMatchOp.PREFIX, value=basin_prefix
139+
),
140+
streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value=""),
141+
op_group_perms=OperationGroupPermissions(
142+
basin=Permission.READ,
143+
stream=Permission.READ,
144+
),
145+
)
146+
147+
token = await s2.issue_access_token(id=token_id, scope=scope)
148+
149+
try:
150+
assert isinstance(token, str)
151+
assert len(token) > 0
152+
finally:
153+
token_info = await s2.revoke_access_token(token_id)
154+
assert token_info.scope == scope
155+
156+
async def test_issue_access_token_with_expiry(self, s2: S2, token_id: str):
157+
expires_at = int(time.time()) + 3600
158+
159+
scope = AccessTokenScope(
160+
streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value=""),
161+
ops=[Operation.READ, Operation.CHECK_TAIL],
162+
)
163+
164+
token = await s2.issue_access_token(
165+
id=token_id,
166+
scope=scope,
167+
expires_at=expires_at,
168+
)
169+
170+
try:
171+
assert isinstance(token, str)
172+
assert len(token) > 0
173+
174+
page = await s2.list_access_tokens(prefix=token_id)
175+
176+
token_info = next((t for t in page.items if t.id == token_id), None)
177+
assert token_info is not None
178+
assert token_info.expires_at == expires_at
179+
assert token_info.scope.streams == scope.streams
180+
assert set(token_info.scope.ops) == set(scope.ops)
181+
182+
finally:
183+
await s2.revoke_access_token(token_id)
184+
185+
async def test_issue_access_token_with_auto_prefix(self, s2: S2, token_id: str):
186+
scope = AccessTokenScope(
187+
streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value="prefix/"),
188+
op_group_perms=OperationGroupPermissions(stream=Permission.READ_WRITE),
189+
)
190+
191+
token = await s2.issue_access_token(
192+
id=token_id,
193+
scope=scope,
194+
auto_prefix_streams=True,
195+
)
196+
197+
try:
198+
assert isinstance(token, str)
199+
assert len(token) > 0
200+
201+
page = await s2.list_access_tokens(prefix=token_id, limit=1)
202+
203+
assert len(page.items) == 1
204+
205+
token_info = page.items[0]
206+
assert token_info is not None
207+
assert token_info.scope == scope
208+
assert token_info.auto_prefix_streams is True
209+
210+
finally:
211+
await s2.revoke_access_token(token_id)

0 commit comments

Comments
 (0)