Coverage for fastapi_restly / testing / _fixtures.py: 89%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 09:54 +0000

1import traceback 

2from contextlib import asynccontextmanager 

3from pathlib import Path 

4from typing import AsyncIterator, Iterator 

5from unittest.mock import AsyncMock, MagicMock, patch 

6 

7import alembic 

8import alembic.command 

9import alembic.config 

10import pytest 

11import pytest_asyncio 

12from fastapi import FastAPI 

13from sqlalchemy.ext.asyncio import AsyncConnection 

14from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession 

15from sqlalchemy.orm import Session as SA_Session 

16 

17from ..db import activate_savepoint_only_mode, fr_globals, get_fr_globals 

18from ._client import RestlyTestClient 

19 

20 

21@pytest.fixture(scope="session") 

22def project_root() -> Path: 

23 """Return the project root directory.""" 

24 # Try to find the project root by looking for pyproject.toml 

25 current = Path.cwd() 

26 while current != current.parent: 

27 if (current / "pyproject.toml").exists(): 

28 return current 

29 current = current.parent 

30 raise Exception("Could not find a pyproject.toml to establish project root") 

31 

32 

33@pytest.fixture(autouse=True, scope="session") 

34def autouse_alembic_upgrade(project_root): 

35 # Only run alembic migrations if the alembic directory exists 

36 alembic_dir = project_root / "alembic" 

37 if not alembic_dir.exists(): 

38 return # Skip if no alembic directory 

39 

40 # TODO: Move project_root to Settings? 

41 alembic_cfg = alembic.config.Config(project_root / "alembic.ini") 

42 alembic_cfg.set_main_option("script_location", str(alembic_dir)) 

43 try: 

44 alembic.command.upgrade(alembic_cfg, "head") 

45 except Exception as exc: 

46 tb = traceback.format_exc() 

47 pytest.exit( 

48 f"Alembic migrations failed: {exc}\n\nTraceback:\n{tb}", returncode=1 

49 ) 

50 

51 

52@pytest.fixture(autouse=True, scope="session") 

53def autouse_savepoint_only_mode_sessions() -> None: 

54 # Only run if database connections are set up 

55 if not fr_globals.async_make_session and not fr_globals.make_session: 

56 return # Skip if no database connections 

57 

58 if fr_globals.async_make_session: 58 ↛ 60line 58 didn't jump to line 60 because the condition on line 58 was always true

59 activate_savepoint_only_mode(fr_globals.async_make_session) 

60 if fr_globals.make_session: 

61 activate_savepoint_only_mode(fr_globals.make_session) 

62 

63 

64@pytest.fixture 

65def _shared_connection(): 

66 # Sync tests need a sync sessionmaker, but async-only projects should still 

67 # be able to use the async_session fixture without one. 

68 if not fr_globals.make_session: 

69 yield None 

70 return 

71 

72 engine = fr_globals.make_session.kw["bind"] 

73 with engine.connect() as conn: 

74 yield conn 

75 

76 

77@pytest_asyncio.fixture 

78async def async_session(_shared_connection) -> AsyncIterator[SA_AsyncSession]: 

79 """ 

80 Pytest fixture providing a database session with savepoint-based isolation. 

81 

82 Each test runs inside a savepoint. At the end of the test, the savepoint is 

83 rolled back, leaving the database clean for the next test. 

84 

85 NOTE: Calling session.rollback() inside a test rolls back to the last savepoint 

86 (created by each patched commit()), NOT to the start of the test. This differs 

87 from production behavior. To undo all changes in a test, use session.rollback() 

88 after each commit(), but be aware that data added before the last commit() is 

89 still visible. 

90 """ 

91 # Only run if database connections are set up 

92 if not fr_globals.async_make_session: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 pytest.skip("Database connection not set up") 

94 

95 async_engine = fr_globals.async_make_session.kw["bind"] 

96 

97 @asynccontextmanager 

98 async def get_bound_async_connection(): 

99 if _shared_connection is None: 99 ↛ 104line 99 didn't jump to line 104 because the condition on line 99 was always true

100 async with async_engine.connect() as async_conn: 

101 yield async_conn 

102 return 

103 

104 async_conn = AsyncConnection(async_engine, sync_connection=_shared_connection) 

105 async with async_conn: 

106 yield async_conn 

107 

108 async with get_bound_async_connection() as async_conn: 

109 async with fr_globals.async_make_session(bind=async_conn) as sess: 

110 async def begin_nested(): 

111 await sess.begin_nested() 

112 return sess 

113 

114 mock_sessionmaker = AsyncMock() 

115 mock_sessionmaker.side_effect = begin_nested 

116 # session.begin() is used as a context manager (async with session.begin():) 

117 # We need it to also return our savepoint session so explicit transaction 

118 # blocks work correctly with our isolation mechanism 

119 mock_sessionmaker.begin.return_value.__aenter__.side_effect = begin_nested 

120 # FIXME: begin().__aexit__ should flush pending changes to make them visible 

121 # within the test, but currently does not. This may cause visibility issues 

122 # when using `async with session.begin(): ...` blocks inside tests. 

123 # Impact: changes inside explicit begin() blocks may not be visible after exit. 

124 

125 async def passthrough_exit(self, exc_type, exc_value, traceback): 

126 await sess.flush() 

127 return False # re-raise any exception 

128 

129 async def patched_commit(self): 

130 await sess.flush() 

131 await sess.begin_nested() 

132 

133 globals_obj = get_fr_globals() 

134 original_async_make_session = globals_obj.async_make_session 

135 globals_obj.async_make_session = mock_sessionmaker 

136 try: 

137 with ( 

138 patch.object(SA_AsyncSession, "__aexit__", passthrough_exit), 

139 patch.object(SA_AsyncSession, "commit", patched_commit), 

140 ): 

141 yield sess 

142 finally: 

143 globals_obj.async_make_session = original_async_make_session 

144 

145 

146@pytest.fixture 

147def session(_shared_connection) -> Iterator[SA_Session]: 

148 """ 

149 Pytest fixture providing a database session with savepoint-based isolation. 

150 

151 Each test runs inside a savepoint. At the end of the test, the savepoint is 

152 rolled back, leaving the database clean for the next test. 

153 

154 NOTE: Calling session.rollback() inside a test rolls back to the last savepoint 

155 (created by each patched commit()), NOT to the start of the test. This differs 

156 from production behavior. To undo all changes in a test, use session.rollback() 

157 after each commit(), but be aware that data added before the last commit() is 

158 still visible. 

159 """ 

160 # Only run if database connections are set up 

161 if not fr_globals.make_session: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 pytest.skip("Database connection not set up") 

163 

164 with fr_globals.make_session(bind=_shared_connection) as sess: 

165 

166 def begin_nested(): 

167 sess.begin_nested() 

168 return sess 

169 

170 mock_sessionmaker = MagicMock() 

171 mock_sessionmaker.side_effect = begin_nested 

172 # session.begin() is used as a context manager (with session.begin():) 

173 # We need it to also return our savepoint session so explicit transaction 

174 # blocks work correctly with our isolation mechanism 

175 mock_sessionmaker.begin.return_value.__enter__.side_effect = begin_nested 

176 # FIXME: begin().__exit__ should flush pending changes to make them visible 

177 # within the test, but currently does not. This may cause visibility issues 

178 # when using `with session.begin(): ...` blocks inside tests. 

179 # Impact: changes inside explicit begin() blocks may not be visible after exit. 

180 

181 def passthrough_exit(self, exc_type, exc_value, traceback): 

182 sess.flush() 

183 return False # re-raise any exception 

184 

185 def patched_commit(self): 

186 sess.flush() 

187 sess.begin_nested() 

188 

189 globals_obj = get_fr_globals() 

190 original_make_session = globals_obj.make_session 

191 globals_obj.make_session = mock_sessionmaker 

192 try: 

193 with ( 

194 patch.object(SA_Session, "__exit__", passthrough_exit), 

195 patch.object(SA_Session, "commit", patched_commit), 

196 ): 

197 yield sess 

198 finally: 

199 globals_obj.make_session = original_make_session 

200 

201 

202@pytest.fixture 

203def app() -> FastAPI: 

204 """Create a FastAPI app instance for testing.""" 

205 return FastAPI() 

206 

207 

208@pytest.fixture 

209def client(app) -> RestlyTestClient: 

210 """Create a RestlyTestClient instance for testing.""" 

211 return RestlyTestClient(app)