Making dashboard a bit more useful
This commit is contained in:
1 parent
3253d8e858
commit
9a9ebab7c8
5 files changed
+620
-2
No files matched your search
@@ -0,0 +1,241 @@
|
||||
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.augmented_part import AugmentedPart
|
||||
from app.models.part_type import PartType
|
||||
|
||||
_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.
|
||||
|
||||
The dashboard's sync/recompute actions run a real background asyncio task
|
||||
concurrently with polling requests, both hitting the DB at once. A real
|
||||
file-backed DB (its own connection pool) supports that, unlike StaticPool's
|
||||
single shared connection (see tests/test_admin_background_jobs.py).
|
||||
"""
|
||||
db_path = tmp_path / "dashboard_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_dashboard_shows_scope_and_skipped_counts(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
scope_t = PartType(name="Scope")
|
||||
db.add(scope_t)
|
||||
await db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
AugmentedPart(
|
||||
description="Alpha 3-18x50",
|
||||
colour_id=1,
|
||||
manufacturer_id=1,
|
||||
part_type_id=scope_t.id,
|
||||
),
|
||||
AugmentedPart(
|
||||
description="Zeta 1-6x24",
|
||||
colour_id=1,
|
||||
manufacturer_id=1,
|
||||
part_type_id=scope_t.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
|
||||
await _web_login(client, "reader@example.com")
|
||||
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Data sync" in resp.text
|
||||
assert "<dd>2</dd>" in resp.text # scope count
|
||||
assert "<dd>0</dd>" in resp.text # nothing imported/skipped yet
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_hides_action_buttons_without_permissions(
|
||||
client, seeded, make_user
|
||||
):
|
||||
await make_user("plain@example.com", role_ids=[seeded["roles"]["user"].id])
|
||||
await _web_login(client, "plain@example.com")
|
||||
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Refresh from OData" not in resp.text
|
||||
assert "Sync augmented parts" not in resp.text
|
||||
assert "Recompute possible fits" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_shows_action_buttons_for_superuser(client, make_user):
|
||||
await make_user("boss@example.com", is_superuser=True)
|
||||
await _web_login(client, "boss@example.com")
|
||||
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Refresh from OData" in resp.text
|
||||
assert "Sync augmented parts" in resp.text
|
||||
assert "Recompute possible fits" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_shows_only_permitted_button(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
perms = seeded["perms"]
|
||||
editor = await role_crud.create(
|
||||
db, name="fits-only-editor", permission_ids=[perms["fits:write"].id]
|
||||
)
|
||||
editor_id = editor.id
|
||||
await make_user("fitsonly@example.com", role_ids=[editor_id])
|
||||
await _web_login(client, "fitsonly@example.com")
|
||||
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Recompute possible fits" in resp.text
|
||||
assert "Refresh from OData" not in resp.text
|
||||
assert "Sync augmented parts" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_refresh_odata_without_config_skips_job(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
perms = seeded["perms"]
|
||||
editor = await role_crud.create(
|
||||
db,
|
||||
name="dash-refresh-noconfig-editor",
|
||||
permission_ids=[perms["imported_parts:write"].id],
|
||||
)
|
||||
editor_id = editor.id
|
||||
await make_user("dashrefresher2@example.com", role_ids=[editor_id])
|
||||
await _web_login(client, "dashrefresher2@example.com")
|
||||
|
||||
resp = await client.post("/dashboard/refresh-odata")
|
||||
assert resp.status_code == 200
|
||||
assert "OData URL is not configured." in resp.text
|
||||
assert _JOB_URL_RE.search(resp.text) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_sync_augmented_parts_runs_in_background(
|
||||
client, seeded, make_user, db_sessionmaker, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("app.web.pages.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="dash-sync-editor",
|
||||
permission_ids=[perms["augmented_parts:write"].id],
|
||||
)
|
||||
editor_id = editor.id
|
||||
await make_user("dashsyncer@example.com", role_ids=[editor_id])
|
||||
await _web_login(client, "dashsyncer@example.com")
|
||||
|
||||
resp = await client.post("/dashboard/sync-augmented-parts")
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
assert "dashboard-sync-card" in final_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_recompute_fits_runs_in_background(
|
||||
client, seeded, make_user, db_sessionmaker, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("app.web.pages.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="dash-recompute-editor", permission_ids=[perms["fits:write"].id]
|
||||
)
|
||||
editor_id = editor.id
|
||||
await make_user("dashrecomputer@example.com", role_ids=[editor_id])
|
||||
await _web_login(client, "dashrecomputer@example.com")
|
||||
|
||||
resp = await client.post("/dashboard/recompute-fits")
|
||||
assert resp.status_code == 200
|
||||
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_dashboard_refresh_odata_runs_in_background(
|
||||
client, seeded, make_user, db_sessionmaker, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("app.web.pages.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.pages.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="dash-refresh-editor",
|
||||
permission_ids=[perms["imported_parts:write"].id],
|
||||
)
|
||||
editor_id = editor.id
|
||||
await make_user("dashrefresher@example.com", role_ids=[editor_id])
|
||||
await _web_login(client, "dashrefresher@example.com")
|
||||
|
||||
resp = await client.post("/dashboard/refresh-odata")
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
Reference in new issue
Block a user