mathly / mathly2 /tests /test_classes.py
3v324v23's picture
Claude Sonnet 5.5
mathly2: written work, rounds, caching, one course for all classes, lesson schedule
0815e01
Raw History Blame Contribute Delete
12.6 kB
import pytest
from fastapi.testclient import TestClient
from mathly2.classes import parse_emails
from mathly2.config import Settings
from mathly2.main import create_app
from mathly2.store import InMemoryStore
MGR, T1, T2, DEV = "mgr@school.test", "t1@school.test", "t2@school.test", "dev@school.test"
def auth(email):
return {"Authorization": f"Bearer dev:{email}"}
@pytest.fixture
def world():
store = InMemoryStore()
store.give_role(MGR, "manager", "setup")
client = TestClient(create_app(Settings(teachers=frozenset({T1, T2}), developers=frozenset({DEV})), store=store))
return client, store
def body(name="讟'3", **kw):
return {"name": name, "lessons": kw.pop("lessons", ["t", "t2"]), **kw}
def make(client, who=MGR, **kw):
return client.post("/api/classes", json=body(**kw), headers=auth(who))
def test_parse_emails_handles_pasted_lists():
valid, invalid = parse_emails(" A@x.test, b@x.test;\nc@x.test a@x.test not-an-email ")
assert valid == ["a@x.test", "b@x.test", "c@x.test"] and invalid == ["not-an-email"]
# ---- the manager makes classes (spec: Manager creates classes)
def test_a_manager_creates_a_class_with_teacher_and_students_and_it_studies_every_lesson(world):
client, _ = world
created = make(client, teacher_email=T1, students="s1@school.test\ns2@school.test not-an-email")
assert created.status_code == 201
row = created.json()
assert row["name"] == "讟'3" and len(row["lessons"]) == 24 and row["teacher_email"] == T1
assert row["ai_enabled"] is False and row["ai_daily_checks"] == 150
assert row["students"] == 2 and row["student_emails"] == ["s1@school.test", "s2@school.test"] and row["invalid"] == ["not-an-email"]
assert [c["name"] for c in client.get("/api/classes", headers=auth(MGR)).json()] == ["讟'3"]
def test_a_developer_can_manage_classes_too(world):
client, _ = world
assert make(client, who=DEV).status_code == 201
def test_a_teacher_a_student_and_a_stranger_cannot_create_classes(world):
client, _ = world
assert make(client, who=T1).status_code == 403
assert make(client, who="kid@school.test").status_code == 403
assert client.post("/api/classes", json=body()).status_code == 401
assert client.get("/api/classes", headers=auth(T1)).status_code == 403
def test_class_name_is_trimmed_checked_and_unique_ignoring_case(world):
client, _ = world
assert make(client, name=" 讞' 1 ").json()["name"] == "讞' 1"
assert make(client, name="讞' 1").status_code == 409
assert make(client, name=" ").status_code == 422
assert make(client, name="x" * 81).status_code == 422
assert make(client, name="x" * 80).status_code == 201
def test_lessons_in_a_request_are_ignored_every_class_studies_every_lesson(world):
client, _ = world
assert len(make(client, lessons=[]).json()["lessons"]) == 24
assert len(make(client, name="B", lessons=["nope"]).json()["lessons"]) == 24
def test_the_teacher_must_be_an_email_and_not_also_a_student(world):
client, _ = world
assert make(client, teacher_email="not-an-email").status_code == 422
clash = make(client, teacher_email=T1, students=f"s@school.test {T1}")
assert clash.status_code == 422 and "转诇诪讬讚" in clash.json()["detail"]
assert client.get("/api/classes", headers=auth(MGR)).json() == [] # nothing was saved
def test_the_ai_budget_is_limited(world):
client, _ = world
assert make(client, ai_daily_checks=-1).status_code == 422
assert make(client, name="B", ai_daily_checks=10001).status_code == 422
assert make(client, name="C", ai_daily_checks=40, ai_enabled=True).json()["ai_enabled"] is True
def test_there_is_no_lesson_list_to_choose_from(world):
client, _ = world
assert client.get("/api/classes/lessons", headers=auth(MGR)).status_code in (404, 405, 422)
# ---- editing and deleting (spec: Editing and deleting a class)
def test_a_manager_edits_everything_and_the_roster_follows(world):
client, store = world
cid = make(client, teacher_email=T1, students="a@school.test b@school.test").json()["id"]
edited = client.put(f"/api/classes/{cid}", json=body("讟'4", teacher_email=T2, students="b@school.test c@school.test",
ai_enabled=True, ai_daily_checks=60), headers=auth(MGR)).json()
assert (edited["name"], edited["lessons"], edited["teacher_email"], edited["ai_enabled"], edited["ai_daily_checks"]) == ("讟'4", edited["lessons"], T2, True, 60)
assert edited["student_emails"] == ["b@school.test", "c@school.test"]
def test_leaving_the_students_out_of_an_edit_keeps_the_roster(world):
client, _ = world
cid = make(client, students="a@school.test").json()["id"]
kept = client.put(f"/api/classes/{cid}", json=body("讟'3"), headers=auth(MGR)).json()
assert kept["student_emails"] == ["a@school.test"]
def test_removing_a_student_through_an_edit_keeps_their_progress(world):
client, store = world
cid = make(client, students="a@school.test").json()["id"]
store.save_progress("u-a", "t_ext", started_at="then")
client.put(f"/api/classes/{cid}", json=body(students=""), headers=auth(MGR))
assert client.get(f"/api/classes/{cid}", headers=auth(MGR)).json()["students"] == 0
assert store.get_progress("u-a", "t_ext")["started_at"] == "then"
def test_editing_cannot_clash_with_another_class_or_make_a_student_the_teacher(world):
client, _ = world
make(client, name="A")
cid = make(client, name="B", students="s@school.test").json()["id"]
assert client.put(f"/api/classes/{cid}", json=body("a"), headers=auth(MGR)).status_code == 409
assert client.put(f"/api/classes/{cid}", json=body("B", teacher_email="s@school.test"), headers=auth(MGR)).status_code == 422
assert client.put("/api/classes/999", json=body("Z"), headers=auth(MGR)).status_code == 404
def test_deleting_a_class_asks_nothing_of_the_students_progress(world):
client, store = world
cid = make(client, students="a@school.test").json()["id"]
store.save_progress("u-a", "t_ext", started_at="then")
assert client.delete(f"/api/classes/{cid}", headers=auth(MGR)).status_code == 204
assert client.delete(f"/api/classes/{cid}", headers=auth(MGR)).status_code == 404
assert client.get("/api/classes", headers=auth(MGR)).json() == []
assert store.get_progress("u-a", "t_ext")["started_at"] == "then"
# ---- the roster (spec: Teacher adds students by email)
def test_the_classes_teacher_adds_a_list_of_students(world):
client, _ = world
cid = make(client, teacher_email=T1).json()["id"]
emails = "\n".join(f"s{i}@school.test" for i in range(25)) + "\ndana@"
result = client.post(f"/api/classes/{cid}/students", json={"emails": emails}, headers=auth(T1)).json()
assert len(result["added"]) == 25 and result["invalid"] == ["dana@"]
assert client.get(f"/api/classes/{cid}", headers=auth(MGR)).json()["students"] == 25
def test_a_student_can_be_added_to_a_second_class(world):
client, store = world
a = make(client, name="A", teacher_email=T1).json()["id"]
b = make(client, name="B", teacher_email=T2).json()["id"]
client.post(f"/api/classes/{a}/students", json={"emails": "s@school.test"}, headers=auth(T1))
again = client.post(f"/api/classes/{b}/students", json={"emails": "s@school.test"}, headers=auth(T2)).json()
assert again["added"] == ["s@school.test"]
assert [c["name"] for c in store.classes_of_student("s@school.test")] == ["A", "B"]
twice = client.post(f"/api/classes/{a}/students", json={"emails": "s@school.test"}, headers=auth(T1)).json()
assert twice["already_here"] == ["s@school.test"] and not twice["added"]
def test_a_teacher_cannot_add_themselves_as_a_student(world):
client, _ = world
cid = make(client, teacher_email=T1).json()["id"]
result = client.post(f"/api/classes/{cid}/students", json={"emails": f"{T1} kid@school.test"}, headers=auth(T1)).json()
assert result["teacher"] == [T1] and result["added"] == ["kid@school.test"]
def test_a_manager_can_use_any_roster_and_another_teacher_cannot(world):
client, _ = world
cid = make(client, teacher_email=T1).json()["id"]
assert client.post(f"/api/classes/{cid}/students", json={"emails": "s@school.test"}, headers=auth(MGR)).status_code == 200
assert client.post(f"/api/classes/{cid}/students", json={"emails": "s@school.test"}, headers=auth(T2)).status_code == 404
assert client.delete(f"/api/classes/{cid}/students/s@school.test", headers=auth(T2)).status_code == 404
assert client.post(f"/api/classes/{cid}/students", json={"emails": "s@school.test"}, headers=auth("kid@school.test")).status_code == 403
def test_the_teacher_removes_a_student_and_the_progress_stays(world):
client, store = world
cid = make(client, teacher_email=T1).json()["id"]
client.post(f"/api/classes/{cid}/students", json={"emails": "s@school.test"}, headers=auth(T1))
store.save_progress("u-s", "t_ext", started_at="then")
assert client.delete(f"/api/classes/{cid}/students/S@school.test", headers=auth(T1)).status_code == 204
assert client.delete(f"/api/classes/{cid}/students/s@school.test", headers=auth(T1)).status_code == 404
assert store.get_progress("u-s", "t_ext")["started_at"] == "then"
def test_a_teacher_cannot_change_the_class_itself(world):
client, _ = world
cid = make(client, teacher_email=T1).json()["id"]
assert client.put(f"/api/classes/{cid}", json=body("other"), headers=auth(T1)).status_code == 403
assert client.delete(f"/api/classes/{cid}", headers=auth(T1)).status_code == 403
assert client.get(f"/api/classes/{cid}", headers=auth(T1)).status_code == 403
def test_too_many_emails_in_one_go(world):
client, _ = world
cid = make(client, teacher_email=T1).json()["id"]
emails = " ".join(f"s{i}@school.test" for i in range(201))
assert client.post(f"/api/classes/{cid}/students", json={"emails": emails}, headers=auth(T1)).status_code == 422
# ---- the teacher's home and the manager's students (spec: Teacher home; Class and student lists)
def test_a_teacher_sees_only_the_classes_they_teach_with_their_students(world):
client, _ = world
make(client, name="A", teacher_email=T1, students="a@school.test")
make(client, name="B", teacher_email=T1, students="b@school.test c@school.test")
make(client, name="C", teacher_email=T2)
mine = client.get("/api/teaching", headers=auth(T1)).json()
assert [c["name"] for c in mine] == ["A", "B"] and [len(c["students"]) for c in mine] == [1, 2]
assert mine[0]["students"][0] == {"email": "a@school.test", "signed_in": False}
assert client.get("/api/teaching", headers=auth("kid@school.test")).status_code == 403
assert client.get("/api/teaching", headers=auth(MGR)).status_code == 403 # a manager does not teach
def test_a_class_teacher_who_is_in_no_setting_still_gets_the_teacher_home(world):
client, _ = world
make(client, teacher_email="newcomer@school.test", students="a@school.test")
assert [c["name"] for c in client.get("/api/teaching", headers=auth("newcomer@school.test")).json()] == ["讟'3"]
assert client.get("/api/me", headers=auth("newcomer@school.test")).json()["teaching"][0]["name"] == "讟'3"
def test_a_developer_sees_every_class_as_a_teacher(world):
client, _ = world
make(client, name="A", teacher_email=T1)
make(client, name="B", teacher_email=T2)
assert [c["name"] for c in client.get("/api/teaching", headers=auth(DEV)).json()] == ["A", "B"]
def test_the_manager_sees_every_student_with_their_classes(world):
client, _ = world
make(client, name="A", students="x@school.test y@school.test")
make(client, name="B", students="y@school.test")
assert client.get("/api/students", headers=auth(MGR)).json() == [
{"email": "x@school.test", "classes": ["A"]}, {"email": "y@school.test", "classes": ["A", "B"]}]
assert client.get("/api/students", headers=auth(T1)).status_code == 403
def test_a_database_that_refuses_a_save_gives_a_clear_503_not_a_name_clash(world):
from mathly2.store import StoreUnavailable
client, store = world
cid = make(client).json()["id"]
def refuse(*args, **kwargs):
raise StoreUnavailable("supabase refused (400): column lessons does not exist")
store.update_class = refuse
answer = client.put(f"/api/classes/{cid}", json=body("讟'3"), headers=auth(MGR))
assert answer.status_code == 503 and "讛诪讬讙专爪讬讜转" in answer.json()["detail"] and "砖诐" not in answer.json()["detail"]