File size: 12,585 Bytes
66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 0815e01 a7ffb22 0815e01 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | 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"]
|