@@ -180,13 +180,71 @@ def _pg_url(_pg_urls: tuple[str, str]) -> str:
180180# ---------------------------------------------------------------------------
181181
182182
183+ async def _bootstrap_osm_database (osm_url : str ) -> None :
184+ """Prepare the provisioned OSM database for the osm alembic tree.
185+
186+ Two things the tree assumes but does not create itself:
187+
188+ * **PostGIS** — the tasking_* migration requires the extension and raises
189+ if it is missing. The container image ships PostGIS but the extension
190+ must be enabled per-database, and the freshly ``CREATE DATABASE``'d
191+ ``osm_test`` doesn't inherit it.
192+ * **users** — owned by the OSM Rails website, not these alembic trees:
193+ migration ``9221408912dd`` adds a unique constraint to it and the
194+ ``tasking_*`` foreign keys reference ``users.id`` / ``users.auth_uid``,
195+ all assuming it already exists. We stand up a stripped-down version
196+ (just the columns the migrations and ``_insert_user_row`` touch).
197+ """
198+ from sqlalchemy import text
199+ from sqlalchemy .ext .asyncio import create_async_engine
200+
201+ engine = create_async_engine (osm_url , isolation_level = "AUTOCOMMIT" )
202+ try :
203+ async with engine .connect () as conn :
204+ await conn .execute (text ("CREATE EXTENSION IF NOT EXISTS postgis" ))
205+ await conn .execute (
206+ text (
207+ # Columns mirror the subset of the OSM Rails `users` table
208+ # that the tasking code reads or writes: the FK targets
209+ # (id, auth_uid), the seed/display columns, and every column
210+ # the TDEI auto-provisioning INSERT populates
211+ # (see TaskingProjectRepository._provision_users_from_tdei).
212+ "CREATE TABLE IF NOT EXISTS users ("
213+ " id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,"
214+ " auth_uid varchar,"
215+ " email varchar,"
216+ " display_name varchar,"
217+ " auth_provider varchar,"
218+ " status varchar,"
219+ " pass_crypt varchar,"
220+ " data_public boolean,"
221+ " email_valid boolean,"
222+ " terms_seen boolean,"
223+ " creation_time timestamp,"
224+ " terms_agreed timestamp,"
225+ " tou_agreed timestamp"
226+ ")"
227+ )
228+ )
229+ finally :
230+ await engine .dispose ()
231+
232+
183233@pytest .fixture (scope = "session" )
184234def _migrated_db (_pg_urls : tuple [str , str ]) -> tuple [str , str ]:
185235 """Run alembic upgrades for both trees against their own databases.
186236
187237 Returns ``(task_url, osm_url)`` after both heads are reached.
188238 """
239+ import asyncio
240+
189241 task_url , osm_url = _pg_urls
242+
243+ # PostGIS + the OSM-website-owned `users` table must exist before the osm
244+ # alembic tree upgrades (its migrations require the extension and
245+ # constrain `users.auth_uid`).
246+ asyncio .run (_bootstrap_osm_database (osm_url ))
247+
190248 repo_root = os .path .dirname (
191249 os .path .dirname (os .path .dirname (os .path .abspath (__file__ )))
192250 )
@@ -214,39 +272,50 @@ def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]:
214272# ---------------------------------------------------------------------------
215273
216274
275+ async def _insert_workspace_row (task_url : str ) -> None :
276+ """Insert the seed workspace row into the TASK database."""
277+ from uuid import uuid4
278+
279+ from sqlalchemy import text
280+ from sqlalchemy .ext .asyncio import create_async_engine
281+
282+ engine = create_async_engine (task_url , future = True )
283+ try :
284+ async with engine .begin () as conn :
285+ await conn .execute (
286+ text (
287+ "INSERT INTO workspaces "
288+ '(id, type, title, "tdeiProjectGroupId", "createdAt", '
289+ ' "createdBy", "createdByName", "externalAppAccess") '
290+ "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) "
291+ "ON CONFLICT (id) DO NOTHING"
292+ ),
293+ {
294+ "id" : SEED_WORKSPACE_ID ,
295+ "type" : "osw" ,
296+ "title" : "Test Workspace" ,
297+ "pgid" : str (SEED_PROJECT_GROUP_ID ),
298+ "uid" : str (uuid4 ()),
299+ "uname" : "seed" ,
300+ },
301+ )
302+ finally :
303+ await engine .dispose ()
304+
305+
217306@pytest .fixture (scope = "session" )
218- async def _seed_workspace_row (_migrated_db : tuple [str , str ]) -> int :
307+ def _seed_workspace_row (_migrated_db : tuple [str , str ]) -> int :
219308 """Insert one workspace row so tenancy/permission gates resolve.
220309
221310 Workspaces live in the TASK database (alongside teams, project groups),
222- so we point this at task_url.
311+ so we point this at task_url. Kept synchronous (driving the async insert
312+ via ``asyncio.run``) because a session-scoped *async* fixture would clash
313+ with pytest-asyncio's function-scoped event loop.
223314 """
224- from uuid import uuid4
225-
226- from sqlalchemy import text
227- from sqlalchemy .ext .asyncio import create_async_engine
315+ import asyncio
228316
229317 task_url , _osm_url = _migrated_db
230- engine = create_async_engine (task_url , future = True )
231- async with engine .begin () as conn :
232- await conn .execute (
233- text (
234- "INSERT INTO workspaces "
235- '(id, type, title, "tdeiProjectGroupId", "createdAt", '
236- ' "createdBy", "createdByName", "externalAppAccess") '
237- "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) "
238- "ON CONFLICT (id) DO NOTHING"
239- ),
240- {
241- "id" : SEED_WORKSPACE_ID ,
242- "type" : "osw" ,
243- "title" : "Test Workspace" ,
244- "pgid" : str (SEED_PROJECT_GROUP_ID ),
245- "uid" : str (uuid4 ()),
246- "uname" : "seed" ,
247- },
248- )
249- await engine .dispose ()
318+ asyncio .run (_insert_workspace_row (task_url ))
250319 return SEED_WORKSPACE_ID
251320
252321
@@ -319,6 +388,23 @@ def _clear_token() -> None:
319388# ---------------------------------------------------------------------------
320389
321390
391+ @pytest .fixture
392+ def app ():
393+ """Real app for the integration suite.
394+
395+ Overrides the unit-suite ``app`` fixture (tests/conftest.py), which swaps
396+ in ``FakeSession`` objects for the DB dependencies. Here we want the real
397+ engines wired up by the autouse ``_per_test_db_sessions`` fixture, so this
398+ just yields the actual FastAPI app untouched. The shared ``client``
399+ fixture depends on ``app`` by name and picks this up for integration
400+ tests. Per-test overrides (sessions, ``validate_token``) are installed and
401+ torn down by their own fixtures.
402+ """
403+ from api .main import app as fastapi_app
404+
405+ return fastapi_app
406+
407+
322408@pytest .fixture (autouse = True )
323409async def _per_test_db_sessions (_pg_urls ):
324410 from sqlalchemy .ext .asyncio import async_sessionmaker , create_async_engine
0 commit comments