Skip to content

Commit 29289cc

Browse files
committed
Merge branch 'automated_testing'
2 parents ea2187a + 20a47f2 commit 29289cc

18 files changed

Lines changed: 1197 additions & 68 deletions

File tree

.github/workflows/test-crud.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: Tests
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'components/rsptx/db/**'
7+
- 'components/rsptx/configuration/**'
8+
- 'components/rsptx/validation/**'
9+
- 'components/rsptx/response_helpers/**'
10+
- 'components/rsptx/endpoint_validators/**'
11+
- 'bases/rsptx/book_server_api/**'
12+
- 'bases/rsptx/assignment_server_api/**'
13+
- 'test/**'
14+
- '.github/workflows/test-crud.yml'
15+
- 'pyproject.toml'
16+
17+
jobs:
18+
crud-tests:
19+
runs-on: ubuntu-latest
20+
21+
services:
22+
postgres:
23+
image: postgres:16
24+
env:
25+
POSTGRES_USER: runestone
26+
POSTGRES_PASSWORD: runestone
27+
POSTGRES_DB: runestone_test
28+
ports:
29+
- 5432:5432
30+
options: >-
31+
--health-cmd pg_isready
32+
--health-interval 10s
33+
--health-timeout 5s
34+
--health-retries 5
35+
36+
env:
37+
SERVER_CONFIG: test
38+
TEST_DBURL: postgresql://runestone:runestone@localhost:5432/runestone_test
39+
DROP_TABLES: "Yes"
40+
41+
steps:
42+
- name: Checkout code
43+
uses: actions/checkout@v4
44+
45+
- name: Set up Python
46+
uses: actions/setup-python@v5
47+
with:
48+
python-version: '3.10'
49+
50+
- name: Install Poetry
51+
uses: snok/install-poetry@v1
52+
with:
53+
virtualenvs-create: true
54+
virtualenvs-in-project: true
55+
56+
- name: Cache Poetry virtualenv
57+
uses: actions/cache@v4
58+
with:
59+
path: .venv
60+
key: venv-${{ runner.os }}-py3.10-${{ hashFiles('pyproject.toml', 'poetry.lock') }}
61+
restore-keys: |
62+
venv-${{ runner.os }}-py3.10-
63+
64+
- name: Install dependencies
65+
run: poetry install --with dev --no-interaction
66+
67+
- name: Run tests
68+
run: |
69+
poetry run pytest -v --tb=short

README.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ If you want to do development on Runestone, or run your own server, you will nee
1717

1818
.. image:: https://readthedocs.org/projects/runestone-monorepo/badge/?version=latest
1919
:target: https://runestone-monorepo.readthedocs.io/en/latest/?badge=latest
20-
:alt: Documentation Status
20+
:alt: Documentation Status
21+
22+
.. image:: https://github.com/RunestoneInteractive/rs/actions/workflows/test-crud.yml/badge.svg
23+
:target: https://github.com/RunestoneInteractive/rs/actions/workflows/test-crud.yml
24+
:alt: Tests
2125

2226
Chat with us on `Discord <https://discord.gg/f3Qmbk9P3U>`_
2327

pyproject.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ standard-asyncore = "^3.13.0"
104104
standard-imghdr = "^3.13.0"
105105
standard-cgi = "^3.13.0"
106106
duckdb = "^1.3.2"
107+
tldextract = "^5.0.0"
107108

108109
[tool.poetry.group.dev.dependencies]
109110
black = "~= 25.0"
@@ -175,6 +176,23 @@ exclude = [
175176
"bases/rsptx/library_server_api",
176177
]
177178

179+
[tool.pytest.ini_options]
180+
asyncio_mode = "auto"
181+
asyncio_default_fixture_loop_scope = "session"
182+
addopts = "--import-mode=importlib"
183+
testpaths = ["test"]
184+
norecursedirs = [
185+
"bases/rsptx/web2py_server",
186+
"bases/rsptx/interactives",
187+
"bases/rsptx/dash_server_api",
188+
".venv",
189+
"projects",
190+
"migrations",
191+
"dist",
192+
"build",
193+
"node_modules",
194+
]
195+
178196
[tool.black]
179197
line-length = 88
180198

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: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,124 @@
1-
from rsptx.assignment_server_api import core
1+
"""
2+
Smoke tests for the assignment server API.
23
4+
Verifies route registration and that unauthenticated requests return the
5+
expected auth error codes (401/422), not 500. No database is required.
6+
"""
37

4-
def test_sample():
5-
assert core is not None
8+
import pytest
9+
from fastapi.testclient import TestClient
10+
11+
from rsptx.assignment_server_api.core import app
12+
13+
14+
@pytest.fixture(scope="module")
15+
def client():
16+
with TestClient(app, raise_server_exceptions=False) as c:
17+
yield c
18+
19+
20+
# ---------------------------------------------------------------------------
21+
# App-level
22+
# ---------------------------------------------------------------------------
23+
24+
def test_app_exists():
25+
assert app is not None
26+
27+
28+
def test_routes_registered():
29+
paths = {r.path for r in app.routes}
30+
assert any("/student" in p for p in paths)
31+
assert any("/instructor" in p for p in paths)
32+
assert any("/peer" in p for p in paths)
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# /student — all routes require auth
37+
# ---------------------------------------------------------------------------
38+
39+
def test_choose_assignment_unauthenticated(client):
40+
resp = client.get("/student/chooseAssignment")
41+
assert resp.status_code in (401, 422)
42+
43+
44+
def test_do_assignment_unauthenticated(client):
45+
resp = client.get("/student/doAssignment")
46+
assert resp.status_code in (401, 422)
47+
48+
49+
def test_update_submit_unauthenticated(client):
50+
resp = client.post("/student/update_submit", json={})
51+
assert resp.status_code in (401, 422)
52+
53+
54+
def test_studyclues_query_unauthenticated(client):
55+
resp = client.post("/student/studyclues_query", json={})
56+
assert resp.status_code in (401, 422)
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# /instructor — all routes require auth + instructor role
61+
# ---------------------------------------------------------------------------
62+
63+
def test_assignments_list_unauthenticated(client):
64+
resp = client.get("/instructor/assignments")
65+
assert resp.status_code in (401, 422)
66+
67+
68+
def test_assignment_get_unauthenticated(client):
69+
resp = client.get("/instructor/assignments/1")
70+
assert resp.status_code in (401, 422)
71+
72+
73+
def test_assignment_create_unauthenticated(client):
74+
resp = client.post("/instructor/assignments", json={})
75+
assert resp.status_code in (401, 422)
76+
77+
78+
def test_assignment_update_unauthenticated(client):
79+
resp = client.put("/instructor/assignments/1", json={})
80+
assert resp.status_code in (401, 422)
81+
82+
83+
def test_assignment_delete_unauthenticated(client):
84+
resp = client.delete("/instructor/assignments/1")
85+
assert resp.status_code in (401, 422)
86+
87+
88+
def test_gradebook_unauthenticated(client):
89+
resp = client.get("/instructor/gradebook")
90+
assert resp.status_code in (401, 422)
91+
92+
93+
def test_new_question_unauthenticated(client):
94+
resp = client.post("/instructor/new_question", json={})
95+
assert resp.status_code in (401, 422)
96+
97+
98+
def test_new_assignment_q_unauthenticated(client):
99+
resp = client.post("/instructor/new_assignment_q", json={})
100+
assert resp.status_code in (401, 422)
101+
102+
103+
def test_search_questions_unauthenticated(client):
104+
resp = client.post("/instructor/search_questions", json={})
105+
assert resp.status_code in (401, 422)
106+
107+
108+
def test_course_roster_unauthenticated(client):
109+
resp = client.get("/instructor/course_roster")
110+
assert resp.status_code in (401, 422)
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# /peer — all routes require auth
115+
# ---------------------------------------------------------------------------
116+
117+
def test_peer_student_unauthenticated(client):
118+
resp = client.get("/peer/student")
119+
assert resp.status_code in (401, 422)
120+
121+
122+
def test_peer_instructor_unauthenticated(client):
123+
resp = client.get("/peer/instructor")
124+
assert resp.status_code in (401, 422)

0 commit comments

Comments
 (0)