Coverage for conftest.py: 77%

1"""

2 camcops_server/conftest.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

11 CamCOPS is free software: you can redistribute it and/or modify

12 it under the terms of the GNU General Public License as published by

13 the Free Software Foundation, either version 3 of the License, or

14 (at your option) any later version.

15

16 CamCOPS is distributed in the hope that it will be useful,

17 but WITHOUT ANY WARRANTY; without even the implied warranty of

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

21 You should have received a copy of the GNU General Public License

22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

23

24===============================================================================

25

26**Configure server self-tests for Pytest.**

27

28"""

29

30# https://gist.githubusercontent.com/kissgyorgy/e2365f25a213de44b9a2/raw/f8b5bbf06c4969bc6bbe5316defef64137c9b1e3/sqlalchemy_conftest.py

31

32 import configparser

33 from io import StringIO

34 import os

35 import tempfile

36 from typing import Generator, TYPE_CHECKING

37

38 import pytest

39 from sqlalchemy import event, MetaData

40 from sqlalchemy.engine import create_engine

41 from sqlalchemy.engine.interfaces import DBAPIConnection

42 from sqlalchemy.orm import Session

43 from sqlalchemy.pool import ConnectionPoolEntry

44

45 import camcops_server.cc_modules.cc_all_models # noqa: F401

46

47# ... import side effects (ensure all models registered)

48

49 from camcops_server.cc_modules.cc_baseconstants import CAMCOPS_SERVER_DIRECTORY

50 from camcops_server.cc_modules.cc_config import get_demo_config

51 from camcops_server.cc_modules.cc_sqlalchemy import (

52 Base,

53 make_memory_sqlite_engine,

54 make_file_sqlite_engine,

55)

56

57 if TYPE_CHECKING:

58 from sqlalchemy.engine.base import Engine

59

60 # Should not need to import from _pytest in later versions of pytest

61 # https://github.com/pytest-dev/pytest/issues/7469

62 from _pytest.config.argparsing import Parser

63 from _pytest.fixtures import FixtureRequest

64

65

66 TEST_DATABASE_FILENAME = os.path.join(

67 CAMCOPS_SERVER_DIRECTORY, "camcops_test.sqlite"

68)

69

70

71 def pytest_addoption(parser: "Parser") -> None:

72 parser.addoption(

73 "--database-in-memory",

74 action="store_false",

75 dest="database_on_disk",

76 default=True,

77 help="Make SQLite database in memory",

78 )

79

80 # Borrowed from pytest-django

81 parser.addoption(

82 "--create-db",

83 action="store_true",

84 dest="create_db",

85 default=False,

86 help="Create the database even if it already exists",

87 )

88

89 parser.addoption(

90 "--mysql",

91 action="store_true",

92 dest="mysql",

93 default=False,

94 help="Use MySQL database instead of SQLite",

95 )

96

97 parser.addoption(

98 "--db-url",

99 dest="db_url",

100 default=(

101 "mysql+mysqldb://camcops:camcops@localhost:3306/test_camcops"

102 "?charset=utf8"

103 ),

104 help="SQLAlchemy test database URL (MySQL only)",

105 )

106

107 parser.addoption(

108 "--echo",

109 action="store_true",

110 dest="echo",

111 default=False,

112 help="Log all SQL statments to the default log handler",

113 )

114

115

116# noinspection PyUnusedLocal

117 def set_sqlite_pragma(

118 dbapi_connection: DBAPIConnection, connection_record: ConnectionPoolEntry

119) -> None:

120 cursor = dbapi_connection.cursor()

121 cursor.execute("PRAGMA foreign_keys=ON")

122 cursor.close()

123

124

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

126 def database_on_disk(request: "FixtureRequest") -> bool:

127 return request.config.getvalue("database_on_disk")

128

129

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

131 def create_db(request: "FixtureRequest", database_on_disk: bool) -> bool:

132 if not database_on_disk:

133 return True

134

135 if not os.path.exists(TEST_DATABASE_FILENAME):

136 return True

137

138 return request.config.getvalue("create_db")

139

140

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

142 def echo(request: "FixtureRequest") -> bool:

143 return request.config.getvalue("echo")

144

145

146# noinspection PyUnusedLocal

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

148 def mysql(request: "FixtureRequest") -> bool:

149 return request.config.getvalue("mysql")

150

151

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

153 def db_url(request: "FixtureRequest") -> bool:

154 return request.config.getvalue("db_url")

155

156

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

158 def tmpdir_obj(

159 request: "FixtureRequest",

160) -> Generator[tempfile.TemporaryDirectory, None, None]:

161 tmpdir_obj = tempfile.TemporaryDirectory()

162

163 yield tmpdir_obj

164

165 tmpdir_obj.cleanup()

166

167

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

169 def config_file(

170 request: "FixtureRequest", tmpdir_obj: tempfile.TemporaryDirectory

171) -> str:

172 # We're going to be using a test (SQLite) database, but we want to

173 # be very sure that nothing writes to a real database! Also, we will

174 # want to read from this dummy config at some point.

175

176 tmpconfigfilename = os.path.join(tmpdir_obj.name, "dummy_config.conf")

177 with open(tmpconfigfilename, "w") as file:

178 file.write(get_config_text())

179

180 return tmpconfigfilename

181

182

183 def get_config_text() -> str:

184 config_text = get_demo_config()

185 parser = configparser.ConfigParser()

186 parser.read_string(config_text)

187

188 with StringIO() as buffer:

189 parser.write(buffer)

190 config_text = buffer.getvalue()

191

192 return config_text

193

194

195# https://gist.github.com/kissgyorgy/e2365f25a213de44b9a2

196# Author says "no [license], feel free to use it"

197# noinspection PyUnusedLocal

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

199 def engine(

200 request: "FixtureRequest",

201 create_db: bool,

202 database_on_disk: bool,

203 echo: bool,

204 mysql: bool,

205 db_url: str,

206) -> Generator["Engine", None, None]:

207

208 if mysql:

209 engine = create_engine_mysql(db_url, create_db, echo)

210 else:

211 engine = create_engine_sqlite(create_db, echo, database_on_disk)

212

213 yield engine

214 engine.dispose()

215

216

217 def create_engine_mysql(db_url: str, create_db: bool, echo: bool) -> "Engine":

218

219 # The database and the user with the given password from db_url

220 # need to exist.

221 # mysql> CREATE DATABASE <db_name>;

222 # mysql> GRANT ALL PRIVILEGES ON <db_name>.*

223 # TO <db_user>@localhost IDENTIFIED BY '<db_password>';

224 engine = create_engine(db_url, echo=echo, pool_pre_ping=True)

225

226 if create_db:

227 Base.metadata.drop_all(engine)

228

229 return engine

230

231

232 def create_engine_sqlite(

233 create_db: bool, echo: bool, database_on_disk: bool

234) -> "Engine":

235 if create_db and database_on_disk:

236 try:

237 os.remove(TEST_DATABASE_FILENAME)

238 except OSError:

239 pass

240

241 if database_on_disk:

242 engine = make_file_sqlite_engine(TEST_DATABASE_FILENAME, echo=echo)

243 else:

244 engine = make_memory_sqlite_engine(echo=echo)

245

246 event.listen(engine, "connect", set_sqlite_pragma)

247

248 return engine

249

250

251# noinspection PyUnusedLocal

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

253 def tables(

254 request: "FixtureRequest", engine: "Engine", create_db: bool

255) -> Generator[None, None, None]:

256 if create_db:

257 Base.metadata.create_all(engine)

258 yield

259

260 # Any post-session clean up would go here

261 # Foreign key constraint on _security_devices prevents this:

262 # Base.metadata.drop_all(engine)

263 # This would only be useful if we wanted to clean up the database

264 # after running the tests

265

266

267# noinspection PyUnusedLocal

268@pytest.fixture

269 def dbsession(

270 request: "FixtureRequest", engine: "Engine", tables: None

271) -> Generator[Session, None, None]:

272 """

273 Returns an sqlalchemy session, and after the test tears down everything

274 properly.

275 """

276

277 connection = engine.connect()

278 # begin the nested transaction

279 transaction = connection.begin()

280 # use the connection with the already started transaction

281 session = Session(bind=connection)

282

283 yield session

284

285 session.close()

286 # roll back the broader transaction

287 transaction.rollback()

288 # put back the connection to the connection pool

289 connection.close()

290

291

292@pytest.fixture

293 def setup(

294 request: "FixtureRequest",

295 engine: "Engine",

296 database_on_disk: bool,

297 mysql: bool,

298 dbsession: Session,

299 tmpdir_obj: tempfile.TemporaryDirectory,

300 config_file: str,

301) -> None:

302 # Pytest prefers function-based tests over unittest.TestCase subclasses and

303 # methods, but it still supports the latter perfectly well.

304 # We use this fixture in cc_unittest.py to store these values into

305 # DemoRequestTestCase and its descendants.

306 request.cls.engine = engine

307 request.cls.database_on_disk = database_on_disk

308 request.cls.dbsession = dbsession

309 request.cls.tmpdir_obj = tmpdir_obj

310 request.cls.db_filename = TEST_DATABASE_FILENAME

311 request.cls.mysql = mysql

312 request.cls.config_file = config_file

313

314

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

316 def temp_engine(

317 request: "FixtureRequest", echo: bool

318) -> Generator["Engine", None, None]:

319 """

320 An in-memory database for testing export via the temp_session fixture.

321 """

322 engine = make_memory_sqlite_engine(echo=echo)

323

324 yield engine

325

326 engine.dispose()

327

328

329# noinspection PyUnusedLocal

330@pytest.fixture

331 def temp_tables(

332 request: "FixtureRequest", temp_engine: "Engine"

333) -> Generator[None, None, None]:

334

335 # Unlike the tables fixture, we don't create any tables as they are created

336 # in the tests themselves and the columns change between tests. So the

337 # scope here is the default 'function', which means they are dropped after

338 # each test, rather than 'session', which would only drop them at the end

339 # of the test run.

340

341 yield

342

343 metadata = MetaData()

344 metadata.reflect(temp_engine)

345 metadata.drop_all(temp_engine)

346

347

348# noinspection PyUnusedLocal

349@pytest.fixture

350 def temp_session(

351 request: "FixtureRequest",

352 temp_engine: "Engine",

353 temp_tables: None,

354) -> Generator[Session, None, None]:

355 """

356 Returns an sqlalchemy session, and after the test tears down everything

357 properly.

358 """

359 connection = temp_engine.connect()

360 # begin the nested transaction

361 transaction = connection.begin()

362 # use the connection with the already started transaction

363 session = Session(bind=connection)

364

365 yield session

366

367 session.close()

368 # roll back the broader transaction

369 transaction.rollback()

370 # put back the connection to the connection pool

371 connection.close()

372

373

374@pytest.fixture

375 def setup_temp_session(

376 request: "FixtureRequest",

377 temp_engine: "Engine",

378 temp_session: Session,

379) -> None:

380 """

381 Use this fixture where a second, in-memory database is required.

382 Slow, so use sparingly.

383 """

384 request.cls.temp_session = temp_session

385 request.cls.temp_engine = temp_engine