Skip to content

Commit 4422fc4

Browse files
Add UI tests
* Make template context robust to real and test environments Closes #46
1 parent c0114a8 commit 4422fc4

14 files changed

Lines changed: 435 additions & 111 deletions

.github/workflows/server-pr.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
working-directory: ${{ github.workspace }}/server
5656
CLIENT_ID: ""
5757
CLIENT_SECRET: ""
58-
DATABASE_URI: ""
58+
DATABASE_URI: "sqlite:///test.db"
5959
steps:
6060
- uses: actions/checkout@v4
6161

server/poetry.lock

Lines changed: 142 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ ruff = "^0.11.0"
3535
bandit = "^1.8.3"
3636
testcontainers = {extras = ["postgres"], version = "^4.10.0"}
3737
coverage = "^7.10.2"
38+
pyquery = "^2.0.1"
3839

3940
[tool.poetry.scripts]
4041
start = "ttfd.main:main"

server/test/conftest.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
from pathlib import Path
66

77
import pytest
8+
from fastapi.testclient import TestClient
89
from sqlalchemy import create_engine
9-
from sqlalchemy.orm import sessionmaker
10+
from sqlalchemy.orm import Session, sessionmaker
1011
from testcontainers.postgres import PostgresContainer
1112

1213
from ttfd import abomination, crud
14+
from ttfd.deps import get_db
15+
from ttfd.main import app
1316
from ttfd.models import Base
1417

1518

@@ -25,7 +28,7 @@ def setup_postgres(request):
2528
@pytest.fixture(scope="session")
2629
def database(setup_postgres):
2730
"""Populate the database with random data."""
28-
engine = create_engine(os.environ["DATABASE_URI"], echo=True, future=True)
31+
engine = create_engine(os.environ["DATABASE_URI"], echo=False, future=True)
2932
Base.metadata.create_all(engine)
3033
# Add data here
3134
# 1. Sample 1000 entries from TTFD data
@@ -46,9 +49,14 @@ def database(setup_postgres):
4649
database=session,
4750
name=metabolite,
4851
common_name=metabolite,
49-
size=0,
52+
size=1,
5053
human=False,
51-
mol={},
54+
mol={
55+
"atoms": [{"x": 0.0, "y": 0.0, "element": "N"}],
56+
"bonds": [],
57+
"width": 0.0,
58+
"height": 0.0,
59+
},
5260
categories=[],
5361
)
5462

@@ -112,3 +120,16 @@ def regression_database(setup_postgres):
112120
yield session
113121

114122
engine.dispose()
123+
124+
125+
@pytest.fixture
126+
def test_client(database: Session):
127+
"""Provide a http client to access the app in a test environment."""
128+
def get_db_override():
129+
return database
130+
131+
app.app.dependency_overrides[get_db] = get_db_override
132+
client = TestClient(app)
133+
134+
yield client
135+
app.app.dependency_overrides.clear()

server/test/test_ui.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Test the UI as the browser would see it."""
2+
3+
from fastapi import status
4+
from pyquery import PyQuery as pq
5+
6+
7+
def test_ui_index_login_button(test_client):
8+
response = test_client.get("/")
9+
assert response.status_code == status.HTTP_200_OK
10+
assert response.headers["content-type"] == "text/html; charset=utf-8"
11+
doc = pq(response.text)
12+
assert doc(".vib-auth-button").text() == "Login"
13+
14+
# Ensure the rest of the document is displayed too
15+
assert "Theoretical Tracer Fate Detection" in doc.text()
16+
17+
18+
def test_ui_404_page(test_client):
19+
response = test_client.get("/does-not-exist")
20+
assert response.status_code == status.HTTP_404_NOT_FOUND
21+
assert response.headers["content-type"] == "text/html; charset=utf-8"
22+
doc = pq(response.text)
23+
assert "Oops!" in doc.text()
24+
25+
# Ensure the rest of the document is displayed too
26+
assert "Theoretical Tracer Fate Detection" in doc.text()
27+
28+
29+
def test_ui_existing_metabolite(test_client):
30+
response = test_client.get("/atoms/L-LACTATE")
31+
assert response.status_code == status.HTTP_200_OK
32+
assert response.headers["content-type"] == "text/html; charset=utf-8"
33+
doc = pq(response.text)
34+
assert doc(".ttfd-button").text() == "Back"
35+
assert len(doc(".ttfd-metabolite-container > svg")) == 1
36+
37+
# Ensure the rest of the document is displayed too
38+
assert "Theoretical Tracer Fate Detection" in doc.text()
39+
40+
41+
def test_ui_nonexisting_metabolite(test_client):
42+
response = test_client.get("/atoms/NOEXIST")
43+
assert response.status_code == status.HTTP_404_NOT_FOUND
44+
assert response.headers["content-type"] == "text/html; charset=utf-8"
45+
doc = pq(response.text)
46+
assert "Oops!" in doc.text()
47+
assert "NOEXIST" in doc.text()
48+
49+
# Ensure the rest of the document is displayed too
50+
assert "Theoretical Tracer Fate Detection" in doc.text()
51+
52+
53+
def test_ui_select_gauge(test_client):
54+
response = test_client.get("/gauge/ALPHA-GLUCOSE/3,4")
55+
assert response.status_code == status.HTTP_200_OK
56+
assert response.headers["content-type"] == "text/html; charset=utf-8"
57+
doc = pq(response.text)
58+
59+
assert {title.text for title in doc(".ttfd-metabolite-title")} == {
60+
"GLT",
61+
"UMP",
62+
"L-LACTATE",
63+
}
64+
65+
# Ensure the rest of the document is displayed too
66+
assert "Theoretical Tracer Fate Detection" in doc.text()

server/ttfd/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,13 @@ def logout(
172172
if user is None:
173173
return response
174174

175-
if (session := request.session.get("token")) is None:
175+
if (token := request.session.get("token")) is None:
176176
return response
177-
if (login_session := crud.get_session(database, session)) is None:
177+
if (session := crud.get_session_by_token(database, token)) is None:
178178
return response
179179

180180
del request.session["token"]
181-
database.delete(login_session)
181+
database.delete(session)
182182
database.commit()
183183
return response
184184

server/ttfd/auth_html_controllers.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,59 +8,57 @@
88
from starlette.templating import _TemplateResponse
99

1010
from ttfd import crud
11-
from ttfd.auth import current_user
12-
from ttfd.context import user_context
11+
from ttfd.context import Context, user_context
1312
from ttfd.deps import get_db
14-
from ttfd.html_controllers import templates
15-
from ttfd.models import User
13+
from ttfd.templates import templates
1614

1715
router = APIRouter()
1816

1917

2018
@router.get("/me", response_model=None)
2119
def me(
2220
request: Request,
23-
user: Annotated[User | None, Depends(current_user)],
21+
context: Annotated[Context, Depends(user_context)],
2422
) -> _TemplateResponse | RedirectResponse:
2523
"""Display the user info page for a logged in user."""
26-
if user is None:
24+
if "user" not in context:
2725
return RedirectResponse("/")
2826
return templates.TemplateResponse(
29-
request=request, name="me.html", context=user_context(user)
27+
request=request, name="me.html", context=dict(context)
3028
)
3129

3230

3331
@router.get("/me/apikey", response_model=None)
3432
def make_api_key_arguments(
3533
request: Request,
36-
user: Annotated[User | None, Depends(current_user)],
34+
context: Annotated[Context, Depends(user_context)],
3735
) -> _TemplateResponse | RedirectResponse:
3836
"""Display the API key creation form to the user."""
39-
if user is None:
37+
if "user" not in context:
4038
return RedirectResponse("/")
4139

4240
return templates.TemplateResponse(
4341
request=request,
4442
name="create_api_key.html",
45-
context=user_context(user),
43+
context=dict(context),
4644
)
4745

4846

4947
@router.post("/me/apikey", response_model=None)
5048
def make_api_key(
5149
request: Request,
52-
user: Annotated[User | None, Depends(current_user)],
50+
context: Annotated[Context, Depends(user_context)],
5351
session: Annotated[Session, Depends(get_db)],
5452
keyname: Annotated[str, Form()],
5553
) -> _TemplateResponse | RedirectResponse:
5654
"""Create an API key."""
57-
if user is None:
55+
if (user := context.get("user")) is None:
5856
return RedirectResponse("/")
5957

6058
api_key = crud.create_api_key(session, name=keyname, user=user)
6159

6260
return templates.TemplateResponse(
6361
request=request,
6462
name="new_api_key.html",
65-
context={"api_key": api_key} | user_context(user),
63+
context={"api_key": api_key} | context,
6664
)

server/ttfd/context.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44

55
from dataclasses import dataclass
66
from itertools import islice
7-
from typing import TYPE_CHECKING, Self, TypedDict, Unpack
7+
from typing import TYPE_CHECKING, Annotated, Self, TypedDict, Unpack
88

9-
from pydantic.tools import parse_obj_as
9+
from fastapi import Depends, Request
1010

11-
from ttfd import molecule, schemas
11+
# This import is needed at _runtime_ by pydantic/fastapi
12+
from sqlalchemy.orm import Session # noqa: TC002
13+
14+
from ttfd import crud, deps, molecule, schemas
15+
from ttfd.auth import current_user
16+
from ttfd.config import settings
1217

1318
if TYPE_CHECKING:
1419
from collections.abc import Callable, Iterable
@@ -29,6 +34,20 @@
2934
]
3035

3136

37+
class AppContext(TypedDict, total=True):
38+
"""Context that all templates require."""
39+
40+
version: str
41+
maintenance_mode: bool
42+
43+
44+
class Context(TypedDict, total=False):
45+
"""TTFD template context."""
46+
47+
ttfd: AppContext
48+
user: User | None
49+
50+
3251
class HistoryData(TypedDict, total=False):
3352
"""The data required to build a history context."""
3453

@@ -297,7 +316,7 @@ def metabolite_image(
297316
"name": metabolite.name,
298317
"common_name": metabolite.common_name,
299318
"image": molecule.render(
300-
parse_obj_as(schemas.Molecule, metabolite.mol),
319+
schemas.Molecule.model_validate(metabolite.mol),
301320
selected=selected,
302321
labels=None,
303322
width=200,
@@ -328,9 +347,25 @@ def _selected_atoms_query(atoms: list[int]) -> Iterable[tuple[str, str]]:
328347
return (("selected", str(atom)) for atom in atoms)
329348

330349

331-
def user_context(user: User | None) -> dict[str, User]:
332-
"""Put the user into the context dictionary."""
333-
if not user:
334-
return {}
350+
def app_context(
351+
session: Annotated[Session, Depends(deps.get_db)],
352+
) -> Context:
353+
"""Create the app context."""
354+
return {
355+
"ttfd": {
356+
"version": settings.server_version,
357+
"maintenance_mode": crud.in_maintenance_mode(session),
358+
}
359+
}
360+
361+
362+
def user_context(
363+
request: Request,
364+
app: Annotated[Context, Depends(app_context)],
365+
session: Annotated[Session, Depends(deps.get_db)],
366+
) -> Context:
367+
"""Create the user context."""
368+
if (user := current_user(request, session)) is None:
369+
return app
335370

336-
return {"user": user}
371+
return app | {"user": user}

server/ttfd/crud.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from operator import itemgetter
1010
from typing import TYPE_CHECKING
1111

12-
from pydantic.tools import parse_obj_as
1312
from sqlalchemy import delete, select, text, update
1413
from sqlalchemy.dialects.postgresql import aggregate_order_by, insert
1514
from sqlalchemy.exc import SQLAlchemyError
@@ -577,7 +576,7 @@ def metabolites_for_path(
577576
name=metabolite.name,
578577
common_name=metabolite.common_name,
579578
input_for_next_step=use,
580-
mol=parse_obj_as(schemas.Molecule, metabolite.mol),
579+
mol=schemas.Molecule.model_validate(metabolite.mol),
581580
)
582581
for (_, use, metabolite) in index_group
583582
]
@@ -1007,6 +1006,13 @@ def get_session(database: Session, session_id: int) -> LoginSession | None:
10071006
return database.execute(qry).scalar_one_or_none()
10081007

10091008

1009+
def get_session_by_token(database: Session, token: str) -> LoginSession | None:
1010+
"""Fetch a login session by its token."""
1011+
qry = select(LoginSession).where(LoginSession.token == token)
1012+
1013+
return database.execute(qry).scalar_one_or_none()
1014+
1015+
10101016
def delete_all_sessions(database: Session, user_id: int) -> None:
10111017
"""Delete all login sessions for a user."""
10121018
qry = delete(LoginSession).where(LoginSession.user_id == user_id)

0 commit comments

Comments
 (0)