import asyncio import re import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.core.database import Base from app.crud import imported_part_config as imported_part_config_crud from app.crud import role as role_crud from app.models.attachment_point import AttachmentPoint from app.models.attachment_point_name import AttachmentPointName from app.models.augmented_part import AugmentedPart from app.models.part_type import PartType from tests.test_scope_import import _scope_row, _workbook _JOB_URL_RE = re.compile(r'hx-get="([^"]+/jobs/[^"]+)"') @pytest_asyncio.fixture async def db_sessionmaker(tmp_path): """Override conftest's in-memory+StaticPool fixture for this module. These tests run a real background asyncio task (the job) concurrently with polling requests, both hitting the DB at once. StaticPool pins every session to one shared raw connection, which can't safely serve two callers at the same time; a real file-backed DB with its own connection pool (how SQLite/Postgres actually behave outside tests) can. """ db_path = tmp_path / "jobs_test.db" engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) maker = async_sessionmaker(engine, expire_on_commit=False) yield maker await engine.dispose() async def _web_login(client, email, password="secret123"): resp = await client.post("/login", data={"email": email, "password": password}) assert resp.status_code == 303 async def _poll_until_done(client, url, max_tries=50): """Follow the same htmx polling loop a browser would, until no further ``hx-get`` job-status link is present in the response.""" body = "" for _ in range(max_tries): resp = await client.get(url) assert resp.status_code == 200 body = resp.text match = _JOB_URL_RE.search(body) if not match: return body url = match.group(1) await asyncio.sleep(0.02) raise AssertionError(f"job did not finish after {max_tries} polls: {body!r}") @pytest.mark.asyncio async def test_scope_import_runs_in_background( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async with db_sessionmaker() as db: perms = seeded["perms"] editor = await role_crud.create( db, name="scope-import-editor", permission_ids=[perms["augmented_parts:write"].id], ) editor_id = editor.id await make_user("importer@example.com", role_ids=[editor_id]) await _web_login(client, "importer@example.com") data = _workbook([_scope_row(product_number="BG-SCOPE-1")]) resp = await client.post( "/admin/scope-import", files={"file": ("scopes.xlsx", data, "application/octet-stream")}, ) assert resp.status_code == 200 assert "scope-import-status" in resp.text match = _JOB_URL_RE.search(resp.text) assert match, resp.text final_body = await _poll_until_done(client, match.group(1)) assert "1 created" in final_body @pytest.mark.asyncio async def test_augmented_parts_sync_runs_in_background( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async with db_sessionmaker() as db: perms = seeded["perms"] editor = await role_crud.create( db, name="sync-editor", permission_ids=[perms["augmented_parts:write"].id] ) editor_id = editor.id await make_user("syncer@example.com", role_ids=[editor_id]) await _web_login(client, "syncer@example.com") resp = await client.post("/admin/augmented-parts/sync") assert resp.status_code == 200 assert 'id="augmented-part-list"' in resp.text match = _JOB_URL_RE.search(resp.text) assert match, resp.text final_body = await _poll_until_done(client, match.group(1)) assert "Sync complete" in final_body @pytest.mark.asyncio async def test_fit_recompute_for_part_runs_in_background( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async with db_sessionmaker() as db: apn = AttachmentPointName(name="Objective") part_t = PartType(name="Scope") db.add_all([apn, part_t]) await db.flush() part = AugmentedPart( description="Test scope", colour_id=1, manufacturer_id=1, part_type_id=part_t.id, ) db.add(part) await db.flush() db.add( AttachmentPoint(augmented_part_id=part.id, attachment_point_name_id=apn.id) ) await db.commit() part_id = part.id perms = seeded["perms"] editor = await role_crud.create( db, name="fit-recompute-editor", permission_ids=[perms["fits:write"].id, perms["augmented_parts:read"].id], ) editor_id = editor.id await make_user("recomputer@example.com", role_ids=[editor_id]) await _web_login(client, "recomputer@example.com") resp = await client.post(f"/admin/augmented-parts/{part_id}/fits/recompute") assert resp.status_code == 200 assert f'id="ap-{part_id}-fits"' in resp.text match = _JOB_URL_RE.search(resp.text) assert match, resp.text final_body = await _poll_until_done(client, match.group(1)) assert "created" in final_body and "removed" in final_body @pytest.mark.asyncio async def test_fit_recompute_all_runs_in_background( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async with db_sessionmaker() as db: perms = seeded["perms"] editor = await role_crud.create( db, name="fit-recompute-all-editor", permission_ids=[perms["fits:write"].id] ) editor_id = editor.id await make_user("recomputerall@example.com", role_ids=[editor_id]) await _web_login(client, "recomputerall@example.com") resp = await client.post("/admin/augmented-parts/fits/recompute") assert resp.status_code == 200 assert "fits-recompute-status" in resp.text match = _JOB_URL_RE.search(resp.text) assert match, resp.text final_body = await _poll_until_done(client, match.group(1)) assert "Recompute complete" in final_body @pytest.mark.asyncio async def test_imported_parts_refresh_runs_in_background( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async def _fake_fetch(url, username, password): return [{"InventoryID": "INV-1", "Description": "Widget"}] monkeypatch.setattr("app.web.admin.odata_service.fetch", _fake_fetch) async with db_sessionmaker() as db: await imported_part_config_crud.upsert( db, odata_url="https://example.test/odata", username=None, password=None ) perms = seeded["perms"] editor = await role_crud.create( db, name="imported-parts-editor", permission_ids=[perms["imported_parts:write"].id], ) editor_id = editor.id await make_user("refresher@example.com", role_ids=[editor_id]) await _web_login(client, "refresher@example.com") resp = await client.post("/admin/imported-parts/refresh") assert resp.status_code == 200 assert 'id="imported-parts-section"' in resp.text match = _JOB_URL_RE.search(resp.text) assert match, resp.text final_body = await _poll_until_done(client, match.group(1)) assert "Sync complete: 1 created, 0 updated." in final_body assert "INV-1" in final_body @pytest.mark.asyncio async def test_imported_parts_refresh_without_config_skips_job( client, seeded, make_user, db_sessionmaker, monkeypatch ): monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker) async with db_sessionmaker() as db: perms = seeded["perms"] editor = await role_crud.create( db, name="imported-parts-editor-noconfig", permission_ids=[perms["imported_parts:write"].id], ) editor_id = editor.id await make_user("refresher2@example.com", role_ids=[editor_id]) await _web_login(client, "refresher2@example.com") resp = await client.post("/admin/imported-parts/refresh") assert resp.status_code == 200 assert "OData URL is not configured." in resp.text # No background job started for this synchronous validation failure. assert _JOB_URL_RE.search(resp.text) is None