Skip to content

Commit 2248122

Browse files
bnmnetpclaude
andcommitted
Add automated CRUD test suite with GitHub Actions CI
- Configure pytest-asyncio (auto mode, session loop scope) in pyproject.toml - Add test/conftest.py: sets SERVER_CONFIG=test and TEST_DBURL env vars before any rsptx imports; session-scoped init_test_db fixture drops/recreates tables and seeds base courses + testuser1 via create_initial_courses_users() - Add test/components/rsptx/db/conftest.py: session-scoped fixtures for test_user, test_course, and overview_course - Replace broken test_crud.py skeleton with smoke tests for seeded data - Add test_user.py: full CRUD cycle for auth_user (create, fetch, update, duplicate detection, delete) - Add test_course.py: fetch by name/id, create, nonexistent returns None - Add test_rslogging.py: create_useinfo_entry, count_useinfo_for, poll summary - Add test_question.py: question CRUD, question_grade create/fetch/update/ duplicate handling - Add test_assignment.py: assignment CRUD, assignment_question linking, visibility filtering - Add .github/workflows/test-crud.yml: runs the 31 CRUD tests on every PR against a PostgreSQL 16 service container Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ea2187a commit 2248122

10 files changed

Lines changed: 624 additions & 61 deletions

File tree

.github/workflows/test-crud.yml

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

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ exclude = [
175175
"bases/rsptx/library_server_api",
176176
]
177177

178+
[tool.pytest.ini_options]
179+
asyncio_mode = "auto"
180+
asyncio_default_fixture_loop_scope = "session"
181+
178182
[tool.black]
179183
line-length = 88
180184

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""
2+
Database-layer test fixtures.
3+
4+
All fixtures here depend on `init_test_db` (defined in test/conftest.py) to
5+
ensure the database is initialised and seeded before use.
6+
"""
7+
8+
import pytest
9+
10+
11+
@pytest.fixture(scope="session")
12+
async def test_user(init_test_db):
13+
"""Return the seeded testuser1 AuthUserValidator."""
14+
from rsptx.db.crud import fetch_user
15+
16+
return await fetch_user("testuser1")
17+
18+
19+
@pytest.fixture(scope="session")
20+
async def test_course(init_test_db):
21+
"""Return the seeded test_course_1 CoursesValidator."""
22+
from rsptx.db.crud import fetch_course
23+
24+
return await fetch_course("test_course_1")
25+
26+
27+
@pytest.fixture(scope="session")
28+
async def overview_course(init_test_db):
29+
"""Return the seeded overview course (testuser1's home course)."""
30+
from rsptx.db.crud import fetch_course
31+
32+
return await fetch_course("overview")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Tests for assignment CRUD operations.
3+
"""
4+
import datetime
5+
import pytest
6+
7+
pytestmark = pytest.mark.asyncio(loop_scope="session")
8+
9+
from rsptx.db.crud import (
10+
fetch_assignments,
11+
fetch_one_assignment,
12+
create_assignment,
13+
update_assignment,
14+
create_assignment_question,
15+
create_question,
16+
)
17+
from rsptx.db.models import AssignmentValidator, AssignmentQuestionValidator, QuestionValidator
18+
from rsptx.response_helpers.core import canonical_utcnow
19+
20+
COURSE_NAME = "test_course_1"
21+
ASSIGN_NAME = "crud_test_assignment"
22+
Q_NAME = "crud_assign_question_1"
23+
24+
25+
@pytest.fixture(scope="session")
26+
async def test_question_for_assignment(init_test_db):
27+
"""A question to attach to assignments."""
28+
return await create_question(
29+
QuestionValidator(
30+
base_course=COURSE_NAME,
31+
name=Q_NAME,
32+
chapter="ch1",
33+
subchapter="sub1",
34+
author="testuser1",
35+
question="Assignment test question?",
36+
timestamp=canonical_utcnow(),
37+
question_type="mchoice",
38+
is_private=False,
39+
from_source=False,
40+
review_flag=False,
41+
)
42+
)
43+
44+
45+
@pytest.fixture(scope="session")
46+
async def test_assignment(test_course, test_question_for_assignment):
47+
"""Create an assignment for this module's tests."""
48+
assignment = await create_assignment(
49+
AssignmentValidator(
50+
course=test_course.id,
51+
name=ASSIGN_NAME,
52+
points=10,
53+
released=False,
54+
description="CRUD test assignment",
55+
duedate=datetime.datetime(2099, 1, 1),
56+
visible=True,
57+
from_source=False,
58+
is_peer=False,
59+
current_index=0,
60+
peer_async_visible=False,
61+
)
62+
)
63+
return assignment
64+
65+
66+
async def test_create_assignment(test_assignment):
67+
"""Created assignment has an id and correct name."""
68+
assert test_assignment is not None
69+
assert test_assignment.id is not None
70+
assert test_assignment.name == ASSIGN_NAME
71+
assert test_assignment.course is not None
72+
73+
74+
async def test_fetch_assignments(test_assignment):
75+
"""fetch_assignments returns a list that includes our assignment."""
76+
assignments = await fetch_assignments(COURSE_NAME, fetch_all=True)
77+
names = [a.name for a in assignments]
78+
assert ASSIGN_NAME in names
79+
80+
81+
async def test_fetch_one_assignment(test_assignment):
82+
"""fetch_one_assignment returns the correct assignment by id."""
83+
fetched = await fetch_one_assignment(test_assignment.id)
84+
assert fetched is not None
85+
assert fetched.id == test_assignment.id
86+
assert fetched.name == ASSIGN_NAME
87+
88+
89+
async def test_update_assignment(test_assignment):
90+
"""Updating the description persists."""
91+
updated = AssignmentValidator(**test_assignment.dict())
92+
updated.description = "Updated description"
93+
await update_assignment(updated)
94+
95+
fetched = await fetch_one_assignment(test_assignment.id)
96+
assert fetched.description == "Updated description"
97+
98+
99+
async def test_create_assignment_question(test_assignment, test_question_for_assignment):
100+
"""Adding a question to an assignment persists."""
101+
aq = await create_assignment_question(
102+
AssignmentQuestionValidator(
103+
assignment_id=test_assignment.id,
104+
question_id=test_question_for_assignment.id,
105+
points=10,
106+
activities_required=0,
107+
reading_assignment=False,
108+
sorting_priority=0,
109+
which_to_grade="best_answer",
110+
autograde="pct_correct",
111+
)
112+
)
113+
assert aq is not None
114+
assert aq.id is not None
115+
assert aq.assignment_id == test_assignment.id
116+
assert aq.question_id == test_question_for_assignment.id
117+
118+
119+
async def test_fetch_visible_assignments(test_assignment):
120+
"""is_visible filter returns our visible assignment."""
121+
visible = await fetch_assignments(COURSE_NAME, is_visible=True)
122+
names = [a.name for a in visible]
123+
assert ASSIGN_NAME in names
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Tests for course CRUD operations.
3+
"""
4+
import datetime
5+
import pytest
6+
7+
pytestmark = pytest.mark.asyncio(loop_scope="session")
8+
9+
from rsptx.db.crud import fetch_course, fetch_course_by_id, create_course
10+
from rsptx.db.models import CoursesValidator
11+
12+
NEW_COURSE_NAME = "crud_test_course"
13+
14+
15+
@pytest.fixture(scope="session")
16+
async def new_course(init_test_db):
17+
"""Create a transient test course for the duration of this module."""
18+
course = await create_course(
19+
CoursesValidator(
20+
course_name=NEW_COURSE_NAME,
21+
base_course="overview",
22+
term_start_date=datetime.date(2024, 1, 1),
23+
login_required=False,
24+
allow_pairs=False,
25+
downloads_enabled=False,
26+
courselevel="",
27+
institution="Test University",
28+
new_server=True,
29+
)
30+
)
31+
yield course
32+
33+
34+
async def test_fetch_seeded_course(test_course):
35+
"""test_course_1 must exist after seed."""
36+
assert test_course is not None
37+
assert test_course.course_name == "test_course_1"
38+
39+
40+
async def test_create_course(new_course):
41+
"""Created course is returned with an id."""
42+
assert new_course is not None
43+
assert new_course.id is not None
44+
assert new_course.course_name == NEW_COURSE_NAME
45+
46+
47+
async def test_fetch_course_by_name(new_course):
48+
"""Fetching by name returns the newly created course."""
49+
fetched = await fetch_course(NEW_COURSE_NAME)
50+
assert fetched is not None
51+
assert fetched.course_name == NEW_COURSE_NAME
52+
assert fetched.institution == "Test University"
53+
54+
55+
async def test_fetch_course_by_id(new_course):
56+
"""Fetching by id returns the same course."""
57+
fetched = await fetch_course_by_id(new_course.id)
58+
assert fetched is not None
59+
assert fetched.course_name == NEW_COURSE_NAME
60+
61+
62+
async def test_fetch_nonexistent_course():
63+
"""Fetching a missing course returns None-wrapped validator."""
64+
result = await fetch_course("this_course_does_not_exist_xyz")
65+
assert result is None or result.course_name is None
Lines changed: 25 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,33 @@
1-
from rsptx.db.crud import *
1+
"""
2+
Smoke tests for the top-level crud module (create_initial_courses_users, etc.).
3+
4+
The detailed per-domain tests live in test_user.py, test_course.py,
5+
test_rslogging.py, test_question.py, and test_assignment.py.
6+
"""
27
import pytest
3-
import asyncio
48

9+
pytestmark = pytest.mark.asyncio(loop_scope="session")
10+
11+
from rsptx.db import crud
12+
from rsptx.db.crud import fetch_user, fetch_course
513

6-
@pytest.mark.asyncio
7-
async def test_create_useinfo_entry():
8-
assert create_useinfo_entry is not None
9-
# Write the code for the test here.
10-
# Tip: use the pytest.raises context manager to test for exceptions.
11-
# create and new entry
12-
with pytest.raises(Exception):
13-
create_useinfo_entry(
14-
sid="test_sid",
15-
div_id="test_div_id",
16-
event="test_event",
17-
act="test_act",
18-
timestamp="test_timestamp",
19-
course_id="test_course_id",
20-
chapter="test_chapter",
21-
subchapter="test_subchapter",
22-
session="test_session",
23-
ip_address="test_ip_address",
24-
)
25-
x = await create_useinfo_entry(
26-
sid="test_sid",
27-
div_id="test_div_id",
28-
event="test_event",
29-
act="test_act",
30-
timestamp="test_timestamp",
31-
course_id="test_course_id",
32-
chapter="test_chapter",
33-
subchapter="test_subchapter",
34-
session="test_session",
35-
ip_address="test_ip_address",
36-
)
37-
assert x is not None
38-
assert x.sid == "test_sid"
39-
assert x.div_id == "test_div_id"
40-
assert x.event == "test_event"
41-
assert x.act == "test_act"
42-
assert x.timestamp == "test_timestamp"
43-
assert x.course_id == "test_course_id"
44-
assert x.chapter == "test_chapter"
45-
assert x.subchapter == "test_subchapter"
46-
assert x.session == "test_session"
47-
assert x.ip_address == "test_ip_address"
4814

15+
async def test_crud_module_importable():
16+
"""The crud module must be importable."""
17+
assert crud is not None
4918

50-
@pytest.mark.asyncio
51-
async def test_create_question_grade_entry():
52-
# Test creating a new QuestionGrade entry
53-
sid = "test_sid"
54-
course_name = "test_course"
55-
qid = "test_qid"
56-
grade = 85
5719

58-
# Call the function to create a new QuestionGrade entry
59-
new_qg = await create_question_grade_entry(sid, course_name, qid, grade)
20+
async def test_seeded_courses_exist(init_test_db):
21+
"""create_initial_courses_users must have seeded the expected base courses."""
22+
for course_name in ("overview", "test_course_1", "fopp", "thinkcspy"):
23+
course = await fetch_course(course_name)
24+
assert course is not None, f"Expected seeded course '{course_name}' not found"
25+
assert course.course_name == course_name
6026

61-
# Assert that the returned object is not None
62-
assert new_qg is not None
6327

64-
# Assert that the returned object has the correct attributes
65-
assert new_qg.sid == sid
66-
assert new_qg.course_name == course_name
67-
assert new_qg.div_id == qid
68-
assert new_qg.score == grade
69-
assert new_qg.comment == "autograded"
28+
async def test_seeded_user_exists(init_test_db):
29+
"""testuser1 must exist after seeding."""
30+
user = await fetch_user("testuser1")
31+
assert user is not None
32+
assert user.username == "testuser1"
33+
assert user.email == "testuser1@example.com"

0 commit comments

Comments
 (0)