Skip to content

Commit 8235da9

Browse files
bnmnetpclaude
andcommitted
Phase 4: add functional route tests with real DB and fake auth
Adds authenticated end-to-end route tests for the book server and assignment server. Uses httpx.AsyncClient + ASGITransport so all tests share the pytest session event loop with the asyncpg connection pool, avoiding "attached to a different loop" errors. Book server (test/bases/rsptx/book_server_api/): - conftest.py: auth_book_client with auth_manager dependency override - test_rslogging.py: 6 tests for POST /logger/bookevent (page, mChoice, fillb, shortanswer, validation errors) Assignment server (test/bases/rsptx/assignment_server_api/): - conftest.py: auth_assignment_client (student), instructor_user with CourseInstructor DB row, auth_instructor_client patching both app.dependency_overrides and rsptx.endpoint_validators.core.auth_manager - test_instructor_routes.py: 4 tests (list assignments, create, get by id, course roster) test/bases/conftest.py: shared student_user fixture CI: expand workflow path triggers to cover bases/ and all test files. Total: 91 tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1245b28 commit 8235da9

6 files changed

Lines changed: 340 additions & 2 deletions

File tree

.github/workflows/test-crud.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ on:
77
- 'components/rsptx/configuration/**'
88
- 'components/rsptx/validation/**'
99
- 'components/rsptx/response_helpers/**'
10-
- 'test/components/rsptx/db/**'
11-
- 'test/conftest.py'
10+
- 'components/rsptx/endpoint_validators/**'
11+
- 'bases/rsptx/book_server_api/**'
12+
- 'bases/rsptx/assignment_server_api/**'
13+
- 'test/**'
1214
- '.github/workflows/test-crud.yml'
1315
- 'pyproject.toml'
1416

test/bases/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Shared fixtures for base-server route tests.
3+
4+
These fixtures set up authenticated TestClients for the book and assignment
5+
servers by overriding the ``auth_manager`` FastAPI dependency with a lambda
6+
that returns the seeded ``testuser1`` object directly.
7+
8+
Fixtures that depend on the DB (``init_test_db``) are session-scoped and
9+
share the single event loop used by all async tests.
10+
"""
11+
12+
import pytest
13+
import pytest_asyncio
14+
15+
16+
@pytest_asyncio.fixture(scope="session")
17+
async def student_user(init_test_db):
18+
"""Return the seeded testuser1 AuthUserValidator (course = overview)."""
19+
from rsptx.db.crud import fetch_user
20+
21+
return await fetch_user("testuser1")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
Assignment-server-specific test fixtures.
3+
4+
All clients use httpx.AsyncClient + ASGITransport so the server runs in the
5+
same asyncio event loop as the test session, avoiding asyncpg "attached to a
6+
different loop" errors.
7+
8+
``auth_assignment_client`` — authenticated as testuser1 (student).
9+
``instructor_user`` — a seeded instructor user with CourseInstructor row.
10+
``auth_instructor_client`` — auth_manager patched to return the instructor user.
11+
"""
12+
13+
import datetime
14+
import pytest_asyncio
15+
import httpx
16+
from unittest.mock import AsyncMock, patch
17+
18+
19+
@pytest_asyncio.fixture(scope="session")
20+
async def auth_assignment_client(student_user):
21+
"""Async HTTP client for the assignment server authenticated as testuser1."""
22+
from rsptx.assignment_server_api.core import app
23+
from rsptx.auth.session import auth_manager
24+
25+
app.dependency_overrides[auth_manager] = lambda: student_user
26+
transport = httpx.ASGITransport(app=app)
27+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
28+
yield client
29+
app.dependency_overrides.pop(auth_manager, None)
30+
31+
32+
@pytest_asyncio.fixture(scope="session")
33+
async def instructor_user(init_test_db):
34+
"""
35+
Create a test instructor user assigned to test_course_1, with a
36+
CourseInstructor row so that is_instructor() returns True for real.
37+
"""
38+
from rsptx.db.crud import (
39+
create_user,
40+
fetch_user,
41+
fetch_course,
42+
create_course_instructor,
43+
)
44+
from rsptx.db.models import AuthUserValidator
45+
46+
course = await fetch_course("test_course_1")
47+
48+
existing = await fetch_user("test_instructor")
49+
if existing:
50+
return existing
51+
52+
user = await create_user(
53+
AuthUserValidator(
54+
username="test_instructor",
55+
first_name="Test",
56+
last_name="Instructor",
57+
password="xxx",
58+
email="test_instructor@example.com",
59+
course_name="test_course_1",
60+
course_id=course.id,
61+
donated=True,
62+
active=True,
63+
accept_tcp=True,
64+
created_on=datetime.datetime(2020, 1, 1),
65+
modified_on=datetime.datetime(2020, 1, 1),
66+
registration_key="",
67+
registration_id="",
68+
reset_password_key="",
69+
)
70+
)
71+
await create_course_instructor(course.id, user.id)
72+
return user
73+
74+
75+
@pytest_asyncio.fixture(scope="session")
76+
async def auth_instructor_client(instructor_user):
77+
"""
78+
Async HTTP client for the assignment server authenticated as test_instructor.
79+
80+
Some routes use @instructor_role_required() (which calls auth_manager() directly
81+
inside the decorator, bypassing FastAPI DI), while others use Depends(auth_manager).
82+
We cover both by:
83+
1. Patching rsptx.endpoint_validators.core.auth_manager for the decorator path.
84+
2. Setting app.dependency_overrides[auth_manager] for the Depends() path.
85+
"""
86+
from rsptx.assignment_server_api.core import app
87+
from rsptx.auth.session import auth_manager
88+
89+
mock_auth = AsyncMock(return_value=instructor_user)
90+
app.dependency_overrides[auth_manager] = lambda: instructor_user
91+
transport = httpx.ASGITransport(app=app)
92+
with patch("rsptx.endpoint_validators.core.auth_manager", mock_auth):
93+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
94+
yield client
95+
app.dependency_overrides.pop(auth_manager, None)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Functional tests for instructor routes in the assignment server.
3+
4+
Routes decorated with @instructor_role_required() call auth_manager() directly
5+
inside the decorator — not via FastAPI's Depends() — so they are tested via
6+
the ``auth_instructor_client`` fixture, which patches auth_manager at the
7+
endpoint_validators module level and uses a real instructor DB user.
8+
9+
Tests are async functions sharing the session event loop with the asyncpg
10+
connection pool.
11+
"""
12+
13+
import pytest
14+
15+
pytestmark = pytest.mark.asyncio(loop_scope="session")
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# GET /instructor/assignments
20+
# ---------------------------------------------------------------------------
21+
22+
async def test_get_assignments(auth_instructor_client):
23+
"""Instructor can list assignments for their course."""
24+
resp = await auth_instructor_client.get("/instructor/assignments")
25+
assert resp.status_code == 200
26+
data = resp.json()
27+
assert "detail" in data
28+
assert "assignments" in data["detail"]
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# POST /instructor/assignments
33+
# ---------------------------------------------------------------------------
34+
35+
async def test_create_assignment(auth_instructor_client):
36+
"""Instructor can create a new assignment."""
37+
payload = {
38+
"name": "route_test_assignment",
39+
"description": "Created by route test",
40+
"duedate": "2099-01-01T00:00:00",
41+
"points": 10,
42+
"kind": "Regular",
43+
"visible": True,
44+
"peer_async_visible": False,
45+
}
46+
resp = await auth_instructor_client.post("/instructor/assignments", json=payload)
47+
assert resp.status_code == 201
48+
data = resp.json()
49+
assert data["detail"]["status"] == "success"
50+
assert "id" in data["detail"]
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# GET /instructor/assignments/{id}
55+
# ---------------------------------------------------------------------------
56+
57+
async def test_get_assignment_by_id(auth_instructor_client):
58+
"""Instructor can fetch a specific assignment by id after creating one."""
59+
payload = {
60+
"name": "route_test_assignment_for_get",
61+
"description": "For GET by id test",
62+
"duedate": "2099-01-01T00:00:00",
63+
"points": 5,
64+
"kind": "Regular",
65+
"visible": True,
66+
"peer_async_visible": False,
67+
}
68+
create_resp = await auth_instructor_client.post("/instructor/assignments", json=payload)
69+
assert create_resp.status_code == 201
70+
assignment_id = create_resp.json()["detail"]["id"]
71+
72+
get_resp = await auth_instructor_client.get(f"/instructor/assignments/{assignment_id}")
73+
assert get_resp.status_code == 200
74+
data = get_resp.json()
75+
assert "assignment" in data["detail"]
76+
assert data["detail"]["assignment"]["id"] == assignment_id
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# GET /instructor/course_roster
81+
# ---------------------------------------------------------------------------
82+
83+
async def test_course_roster(auth_instructor_client):
84+
"""Instructor can retrieve the course roster."""
85+
resp = await auth_instructor_client.get("/instructor/course_roster")
86+
assert resp.status_code == 200
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Book-server-specific test fixtures.
3+
4+
``auth_book_client`` provides an httpx.AsyncClient with ASGITransport so the
5+
book server runs in the *same* asyncio event loop as the test session. This
6+
avoids the "attached to a different loop" error that occurs when TestClient
7+
(which spins up its own event loop) tries to reuse asyncpg connections that
8+
were created in the pytest session loop.
9+
10+
All tests that use this client must be async functions.
11+
"""
12+
13+
import pytest_asyncio
14+
import httpx
15+
16+
17+
@pytest_asyncio.fixture(scope="session")
18+
async def auth_book_client(student_user):
19+
"""Async HTTP client for the book server authenticated as testuser1."""
20+
from rsptx.book_server_api.main import app
21+
from rsptx.auth.session import auth_manager
22+
23+
app.dependency_overrides[auth_manager] = lambda: student_user
24+
transport = httpx.ASGITransport(app=app)
25+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
26+
yield client
27+
app.dependency_overrides.pop(auth_manager, None)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Functional tests for POST /logger/bookevent.
3+
4+
Requires a running PostgreSQL instance (TEST_DBURL) with the test schema
5+
initialised via the ``init_test_db`` session fixture.
6+
7+
All requests are authenticated as ``testuser1`` via the ``auth_book_client``
8+
fixture, which overrides the ``auth_manager`` FastAPI dependency.
9+
10+
Tests are async functions so they share the session event loop with the
11+
asyncpg connection pool — using httpx.AsyncClient + ASGITransport instead
12+
of TestClient avoids the "attached to a different loop" error.
13+
"""
14+
15+
import pytest
16+
17+
pytestmark = pytest.mark.asyncio(loop_scope="session")
18+
19+
COURSE = "test_course_1"
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# helpers
24+
# ---------------------------------------------------------------------------
25+
26+
def _bookevent(event, act, div_id, **extra):
27+
payload = {
28+
"event": event,
29+
"act": act,
30+
"div_id": div_id,
31+
"course_name": COURSE,
32+
}
33+
payload.update(extra)
34+
return payload
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# /logger/bookevent
39+
# ---------------------------------------------------------------------------
40+
41+
async def test_bookevent_page_view(auth_book_client):
42+
"""A simple page-view event is accepted and returns 2xx."""
43+
resp = await auth_book_client.post(
44+
"/logger/bookevent",
45+
json=_bookevent("page", "view", "ch1_introduction"),
46+
)
47+
assert resp.status_code in (200, 201)
48+
49+
50+
async def test_bookevent_mchoice(auth_book_client):
51+
"""A multiple-choice answer event is accepted and returns 2xx."""
52+
resp = await auth_book_client.post(
53+
"/logger/bookevent",
54+
json=_bookevent(
55+
"mChoice",
56+
"answer:A:correct",
57+
"ch1_q1",
58+
answer="A",
59+
correct=True,
60+
),
61+
)
62+
assert resp.status_code in (200, 201)
63+
64+
65+
async def test_bookevent_fillb(auth_book_client):
66+
"""A fill-in-the-blank answer event is accepted and returns 2xx."""
67+
resp = await auth_book_client.post(
68+
"/logger/bookevent",
69+
json=_bookevent(
70+
"fillb",
71+
"answer:hello:correct",
72+
"ch1_q2",
73+
answer="hello",
74+
correct=True,
75+
percent=1.0,
76+
),
77+
)
78+
assert resp.status_code in (200, 201)
79+
80+
81+
async def test_bookevent_shortanswer(auth_book_client):
82+
"""A short-answer event is accepted and returns 2xx."""
83+
resp = await auth_book_client.post(
84+
"/logger/bookevent",
85+
json=_bookevent(
86+
"shortanswer",
87+
"answer:This is my answer",
88+
"ch1_sa1",
89+
answer="This is my answer",
90+
),
91+
)
92+
assert resp.status_code in (200, 201)
93+
94+
95+
async def test_bookevent_missing_required_fields(auth_book_client):
96+
"""A request missing required fields returns 422."""
97+
resp = await auth_book_client.post(
98+
"/logger/bookevent",
99+
json={"event": "page"}, # missing act, div_id, course_name
100+
)
101+
assert resp.status_code == 422
102+
103+
104+
async def test_bookevent_empty_body(auth_book_client):
105+
"""An empty body returns 422."""
106+
resp = await auth_book_client.post("/logger/bookevent", json={})
107+
assert resp.status_code == 422

0 commit comments

Comments
 (0)