Skip to content

Commit 900b0e7

Browse files
committed
add tests
1 parent 0085e5e commit 900b0e7

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
"""
2+
Tests for SQLAlchemy infrastructure.
3+
4+
These tests verify that the SQLAlchemy module correctly:
5+
1. Creates async engines with proper configuration
6+
2. Initializes session factories
7+
3. Provides database sessions via get_session()
8+
4. Handles connection pooling
9+
5. Properly disposes of resources
10+
"""
11+
12+
import os
13+
from unittest import mock
14+
15+
import pytest
16+
from sqlalchemy import select, text
17+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
18+
19+
from backend.data import sqlalchemy as sa
20+
21+
22+
class TestDatabaseURLConversion:
23+
"""Test database URL conversion from Prisma to asyncpg format."""
24+
25+
def test_get_database_url_converts_prisma_format(self):
26+
"""Test that Prisma URL is converted to asyncpg format."""
27+
# Mock the Config to return a Prisma-style URL
28+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
29+
MockConfig.return_value.database_url = (
30+
"postgresql://user:pass@localhost:5432/testdb?schema=platform"
31+
)
32+
33+
result = sa.get_database_url()
34+
35+
assert result.startswith("postgresql+asyncpg://")
36+
assert "?schema=platform" not in result # Schema param should be removed
37+
assert "user:pass@localhost:5432/testdb" in result
38+
39+
def test_get_database_url_removes_schema_parameter(self):
40+
"""Test that schema parameter is removed from URL."""
41+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
42+
MockConfig.return_value.database_url = (
43+
"postgresql://user:pass@localhost:5432/db?schema=test&other=param"
44+
)
45+
46+
result = sa.get_database_url()
47+
48+
assert "schema=" not in result
49+
50+
def test_get_database_schema_extracts_schema_name(self):
51+
"""Test that schema name is correctly extracted."""
52+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
53+
MockConfig.return_value.database_url = (
54+
"postgresql://user:pass@localhost:5432/db?schema=custom_schema"
55+
)
56+
57+
result = sa.get_database_schema()
58+
59+
assert result == "custom_schema"
60+
61+
def test_get_database_schema_defaults_to_platform(self):
62+
"""Test that schema defaults to 'platform' if not specified."""
63+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
64+
MockConfig.return_value.database_url = (
65+
"postgresql://user:pass@localhost:5432/db"
66+
)
67+
68+
result = sa.get_database_schema()
69+
70+
assert result == "platform"
71+
72+
73+
class TestEngineCreation:
74+
"""Test async engine creation and configuration."""
75+
76+
def test_create_engine_returns_async_engine(self):
77+
"""Test that create_engine returns an AsyncEngine instance."""
78+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
79+
MockConfig.return_value.database_url = (
80+
"postgresql://user:pass@localhost:5432/testdb?schema=platform"
81+
)
82+
MockConfig.return_value.sqlalchemy_pool_size = 5
83+
MockConfig.return_value.sqlalchemy_max_overflow = 2
84+
MockConfig.return_value.sqlalchemy_pool_timeout = 20
85+
MockConfig.return_value.sqlalchemy_connect_timeout = 10
86+
MockConfig.return_value.sqlalchemy_echo = False
87+
88+
engine = sa.create_engine()
89+
90+
assert isinstance(engine, AsyncEngine)
91+
# Note: We don't test actual DB connection here, just engine creation
92+
93+
def test_create_engine_uses_config_pool_settings(self):
94+
"""Test that engine uses pool settings from config."""
95+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
96+
MockConfig.return_value.database_url = (
97+
"postgresql://user:pass@localhost:5432/testdb?schema=platform"
98+
)
99+
MockConfig.return_value.sqlalchemy_pool_size = 15
100+
MockConfig.return_value.sqlalchemy_max_overflow = 10
101+
MockConfig.return_value.sqlalchemy_pool_timeout = 45
102+
MockConfig.return_value.sqlalchemy_connect_timeout = 15
103+
MockConfig.return_value.sqlalchemy_echo = True
104+
105+
engine = sa.create_engine()
106+
107+
# Verify pool configuration is set (engine.pool.size() would require connection)
108+
assert engine is not None
109+
110+
111+
class TestSessionFactory:
112+
"""Test session factory creation."""
113+
114+
def test_create_session_factory_returns_sessionmaker(self):
115+
"""Test that create_session_factory returns a session maker."""
116+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
117+
MockConfig.return_value.database_url = (
118+
"postgresql://user:pass@localhost:5432/testdb?schema=platform"
119+
)
120+
MockConfig.return_value.sqlalchemy_pool_size = 5
121+
MockConfig.return_value.sqlalchemy_max_overflow = 2
122+
MockConfig.return_value.sqlalchemy_pool_timeout = 20
123+
MockConfig.return_value.sqlalchemy_connect_timeout = 10
124+
MockConfig.return_value.sqlalchemy_echo = False
125+
126+
engine = sa.create_engine()
127+
session_factory = sa.create_session_factory(engine)
128+
129+
assert session_factory is not None
130+
# The factory is an async_sessionmaker, we can verify it exists
131+
132+
133+
class TestInitialization:
134+
"""Test module initialization."""
135+
136+
def test_initialize_sets_global_references(self):
137+
"""Test that initialize sets module-level engine and session factory."""
138+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
139+
MockConfig.return_value.database_url = (
140+
"postgresql://user:pass@localhost:5432/testdb?schema=platform"
141+
)
142+
MockConfig.return_value.sqlalchemy_pool_size = 5
143+
MockConfig.return_value.sqlalchemy_max_overflow = 2
144+
MockConfig.return_value.sqlalchemy_pool_timeout = 20
145+
MockConfig.return_value.sqlalchemy_connect_timeout = 10
146+
MockConfig.return_value.sqlalchemy_echo = False
147+
148+
engine = sa.create_engine()
149+
sa.initialize(engine)
150+
151+
# After initialization, _engine and _session_factory should be set
152+
assert sa._engine is not None
153+
assert sa._session_factory is not None
154+
155+
@pytest.mark.asyncio
156+
async def test_get_session_raises_without_initialization(self):
157+
"""Test that get_session raises error if not initialized."""
158+
# Reset globals
159+
sa._engine = None
160+
sa._session_factory = None
161+
162+
with pytest.raises(RuntimeError, match="SQLAlchemy not initialized"):
163+
async with sa.get_session() as session:
164+
pass
165+
166+
167+
@pytest.mark.asyncio
168+
class TestSessionLifecycle:
169+
"""Test database session lifecycle using real database."""
170+
171+
async def test_get_session_provides_valid_session(self):
172+
"""Test that get_session provides a working AsyncSession."""
173+
# This test requires DATABASE_URL to be set and database to be accessible
174+
if not os.getenv("DATABASE_URL"):
175+
pytest.skip("DATABASE_URL not set, skipping integration test")
176+
177+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
178+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
179+
MockConfig.return_value.sqlalchemy_pool_size = 2
180+
MockConfig.return_value.sqlalchemy_max_overflow = 1
181+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
182+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
183+
MockConfig.return_value.sqlalchemy_echo = False
184+
185+
engine = sa.create_engine()
186+
sa.initialize(engine)
187+
188+
try:
189+
# Test using get_session as a context manager
190+
async with sa.get_session() as session:
191+
assert isinstance(session, AsyncSession)
192+
193+
# Execute a simple query to verify connection works
194+
result = await session.execute(text("SELECT 1"))
195+
value = result.scalar()
196+
assert value == 1
197+
198+
finally:
199+
await sa.dispose()
200+
201+
async def test_get_session_commits_on_success(self):
202+
"""Test that session commits when no exception occurs."""
203+
if not os.getenv("DATABASE_URL"):
204+
pytest.skip("DATABASE_URL not set, skipping integration test")
205+
206+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
207+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
208+
MockConfig.return_value.sqlalchemy_pool_size = 2
209+
MockConfig.return_value.sqlalchemy_max_overflow = 1
210+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
211+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
212+
MockConfig.return_value.sqlalchemy_echo = False
213+
214+
engine = sa.create_engine()
215+
sa.initialize(engine)
216+
217+
try:
218+
# Create a session and perform an operation
219+
async with sa.get_session() as session:
220+
# Just execute a query - commit should happen automatically
221+
await session.execute(text("SELECT 1"))
222+
223+
# If we get here without exception, commit worked
224+
assert True
225+
226+
finally:
227+
await sa.dispose()
228+
229+
async def test_get_session_rolls_back_on_exception(self):
230+
"""Test that session rolls back when exception occurs."""
231+
if not os.getenv("DATABASE_URL"):
232+
pytest.skip("DATABASE_URL not set, skipping integration test")
233+
234+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
235+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
236+
MockConfig.return_value.sqlalchemy_pool_size = 2
237+
MockConfig.return_value.sqlalchemy_max_overflow = 1
238+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
239+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
240+
MockConfig.return_value.sqlalchemy_echo = False
241+
242+
engine = sa.create_engine()
243+
sa.initialize(engine)
244+
245+
try:
246+
with pytest.raises(ValueError):
247+
async with sa.get_session() as session:
248+
# Execute valid query
249+
await session.execute(text("SELECT 1"))
250+
# Raise exception to trigger rollback
251+
raise ValueError("Test exception")
252+
253+
# Session should have been rolled back
254+
assert True
255+
256+
finally:
257+
await sa.dispose()
258+
259+
260+
@pytest.mark.asyncio
261+
class TestDisposal:
262+
"""Test resource disposal."""
263+
264+
async def test_dispose_closes_connections(self):
265+
"""Test that dispose closes all connections."""
266+
if not os.getenv("DATABASE_URL"):
267+
pytest.skip("DATABASE_URL not set, skipping integration test")
268+
269+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
270+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
271+
MockConfig.return_value.sqlalchemy_pool_size = 2
272+
MockConfig.return_value.sqlalchemy_max_overflow = 1
273+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
274+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
275+
MockConfig.return_value.sqlalchemy_echo = False
276+
277+
engine = sa.create_engine()
278+
sa.initialize(engine)
279+
280+
# Use a session to establish connection
281+
async with sa.get_session() as session:
282+
await session.execute(text("SELECT 1"))
283+
284+
# Dispose should close connections
285+
await sa.dispose()
286+
287+
# After disposal, globals should be None
288+
assert sa._engine is None
289+
assert sa._session_factory is None
290+
291+
async def test_dispose_handles_multiple_calls(self):
292+
"""Test that dispose can be called multiple times safely."""
293+
if not os.getenv("DATABASE_URL"):
294+
pytest.skip("DATABASE_URL not set, skipping integration test")
295+
296+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
297+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
298+
MockConfig.return_value.sqlalchemy_pool_size = 2
299+
MockConfig.return_value.sqlalchemy_max_overflow = 1
300+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
301+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
302+
MockConfig.return_value.sqlalchemy_echo = False
303+
304+
engine = sa.create_engine()
305+
sa.initialize(engine)
306+
307+
# First disposal
308+
await sa.dispose()
309+
310+
# Second disposal should not raise error
311+
await sa.dispose()
312+
313+
assert sa._engine is None
314+
315+
316+
@pytest.mark.asyncio
317+
class TestConnectionPooling:
318+
"""Test connection pooling behavior."""
319+
320+
async def test_multiple_sessions_share_pool(self):
321+
"""Test that multiple sessions use the same connection pool."""
322+
if not os.getenv("DATABASE_URL"):
323+
pytest.skip("DATABASE_URL not set, skipping integration test")
324+
325+
with mock.patch("backend.data.sqlalchemy.Config") as MockConfig:
326+
MockConfig.return_value.database_url = os.getenv("DATABASE_URL")
327+
MockConfig.return_value.sqlalchemy_pool_size = 3
328+
MockConfig.return_value.sqlalchemy_max_overflow = 2
329+
MockConfig.return_value.sqlalchemy_pool_timeout = 10
330+
MockConfig.return_value.sqlalchemy_connect_timeout = 5
331+
MockConfig.return_value.sqlalchemy_echo = False
332+
333+
engine = sa.create_engine()
334+
sa.initialize(engine)
335+
336+
try:
337+
# Create multiple sessions sequentially
338+
async with sa.get_session() as session1:
339+
result1 = await session1.execute(text("SELECT 1"))
340+
assert result1.scalar() == 1
341+
342+
async with sa.get_session() as session2:
343+
result2 = await session2.execute(text("SELECT 2"))
344+
assert result2.scalar() == 2
345+
346+
async with sa.get_session() as session3:
347+
result3 = await session3.execute(text("SELECT 3"))
348+
assert result3.scalar() == 3
349+
350+
# All sessions should have worked, sharing the pool
351+
assert True
352+
353+
finally:
354+
await sa.dispose()

0 commit comments

Comments
 (0)