Skip to content

Commit c2945a8

Browse files
committed
Fixes of TM tests
1 parent 2eab87c commit c2945a8

7 files changed

Lines changed: 355 additions & 52 deletions

File tree

alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@
1414

1515
# revision identifiers, used by Alembic.
1616
revision: str = "5303f61d7b3a"
17-
down_revision: Union[str, None] = "9221408912dd"
17+
down_revision: Union[str, None] = "a1b2c3d4e5f6"
1818
branch_labels: Union[str, Sequence[str], None] = None
1919
depends_on: Union[str, Sequence[str], None] = None
2020

2121
_SQL_DIR = pathlib.Path(__file__).parent.parent / "sql"
2222

2323

2424
def upgrade() -> None:
25+
# This SQL function references OSM core tables (nodes, ways, way_nodes)
26+
# owned by the Rails website. Disable body validation for this transaction
27+
# so the function can be created even when those tables aren't present yet
28+
# (a fresh DB where alembic runs before the Rails schema load, or the
29+
# integration-test container). pg_dump does the same when restoring
30+
# SQL-language functions. The function is only invoked at runtime, by
31+
# which point the OSM tables exist.
32+
op.execute(text("SET LOCAL check_function_bodies = off"))
2533
op.execute(text((_SQL_DIR / "osm_augmented_diff.sql").read_text()))
2634

2735

api/src/tasking/projects/repository.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1006,11 +1006,25 @@ async def add_role(
10061006

10071007
missing = await self._missing_user_auth_uids([body.user_id])
10081008
if missing: # User never logged in, so we try to provision them from TDEI
1009-
provisioned_users = await self._provision_users_from_tdei(
1009+
still_missing = await self._provision_users_from_tdei(
10101010
missing,
10111011
project_group_id=project_group_id,
10121012
bearer_token=user_token,
10131013
)
1014+
# If TDEI doesn't list the user either, surface the same structured
1015+
# 422 the create path returns rather than falling through to a raw
1016+
# foreign-key violation with a generic string detail.
1017+
if still_missing:
1018+
raise HTTPException(
1019+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
1020+
detail={
1021+
"message": (
1022+
"The `user_id` is not a member of this "
1023+
"workspace's project group in TDEI."
1024+
),
1025+
"missing_user_ids": still_missing,
1026+
},
1027+
)
10141028

10151029
from sqlalchemy import text
10161030

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,22 @@ dependencies = [
3737
"shapely>=2.1.2",
3838
]
3939

40+
[project.optional-dependencies]
41+
# Integration tests boot a real PostGIS database via testcontainers (needs a
42+
# running Docker daemon). Install with `uv sync --extra integration` or
43+
# `--all-extras`, then run them with `pytest -m integration`.
44+
integration = [
45+
"testcontainers[postgres]>=4.0.0",
46+
]
47+
4048
[tool.pytest.ini_options]
4149
addopts = "-v --cov=api --cov-report=term-missing"
4250
testpaths = ["tests"]
4351
asyncio_mode = "auto"
4452
asyncio_default_fixture_loop_scope = "function"
53+
markers = [
54+
"integration: tests that require a live PostGIS database (Docker + testcontainers)",
55+
]
4556

4657
[tool.black]
4758
line-length = 88

scripts/ci.sh

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@
22
# Run the CI checks locally, mirroring .github/workflows/ci.yml.
33
#
44
# Runs every check by default; a failure in one step does not stop the others,
5-
# and the script exits non-zero if any step failed. Pass --fail-fast to stop at
6-
# the first failure instead.
5+
# and the script exits non-zero if any step failed.
6+
#
7+
# Flags:
8+
# --fail-fast stop at the first failing step instead of running all
9+
# --integration also run the integration suite (`pytest -m integration`),
10+
# which boots a real PostGIS database via testcontainers and
11+
# therefore needs a running Docker daemon
712
set -uo pipefail
813

914
cd "$(dirname "$0")/.."
1015

1116
fail_fast=0
12-
[[ "${1:-}" == "--fail-fast" ]] && fail_fast=1
17+
integration=0
18+
for arg in "$@"; do
19+
case "${arg}" in
20+
--fail-fast) fail_fast=1 ;;
21+
--integration) integration=1 ;;
22+
*) echo "Unknown option: ${arg}" >&2; exit 2 ;;
23+
esac
24+
done
1325

1426
failed=()
1527

@@ -42,4 +54,8 @@ run "Check code formatting (black)" uv run black --check .
4254
run "Type-check with pyright" uvx pyright --pythonpath .venv/bin/python api tests
4355
run "Run tests" uv run pytest tests
4456

57+
if [[ "${integration}" == 1 ]]; then
58+
run "Run integration tests" uv run pytest tests -m integration
59+
fi
60+
4561
summary_and_exit

tests/integration/conftest.py

Lines changed: 112 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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")
184234
def _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)
323409
async def _per_test_db_sessions(_pg_urls):
324410
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

0 commit comments

Comments
 (0)