"""End-to-end smoke test for the assignment workflow. Verifies, against a live entity-A admin, that every step of the classroom -> roster -> teachers -> course/section -> per-batch override flow works through the REST API the new UI uses: 1. List classrooms in scope; pick the first one. 2. Set the classroom roster + homeroom teachers. 3. Assign a course (with section + term) -> server creates a batch and propagates roster + teachers automatically. 4. List the batch's enrolled students and teachers (must equal classroom). 5. Override the batch -> remove one student, add a different teacher. 6. Verify the per-batch state changed (server is source of truth). If anything looks off at any step we abort with a precise error. """ from __future__ import annotations import json import sys import urllib.request import urllib.error API = "http://127.0.0.1:8069" LOGIN = "admin@encoach.test" PASSWORD = "admin123" def _req(method, path, token=None, body=None): url = f"{API}{path}" data = None headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" if body is not None: data = json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode() or "{}" return r.status, json.loads(raw) if raw.strip() else {} except urllib.error.HTTPError as e: raw = e.read().decode() if e.fp else "" try: parsed = json.loads(raw) except Exception: parsed = {"raw": raw} return e.code, parsed def must(label, code, data, ok=(200, 201)): if code not in ok: print(f" FAIL {label}: expected {ok}, got {code} -> {data}") sys.exit(1) print(f" OK {label} ({code})") def login(): code, data = _req("POST", "/api/login", body={"login": LOGIN, "password": PASSWORD}) must("login", code, data) return data["access_token"] def main(): print("== assignment workflow smoke ==") token = login() # Step 1: pick a classroom code, data = _req("GET", "/api/classrooms?size=10", token) must("list classrooms", code, data) items = data.get("items") or data.get("data") or [] if not items: print(" SKIP no classrooms in scope; nothing to test.") return classroom = items[0] classroom_id = classroom["id"] print(f" -> using classroom #{classroom_id} '{classroom['name']}'") # Step 2: set roster + homeroom teachers code, students = _req("GET", "/api/students?size=20", token) must("list students", code, students) student_ids = [s["id"] for s in (students.get("items") or students.get("data") or [])][:5] if len(student_ids) < 2: print(f" SKIP need at least 2 students in scope, found {len(student_ids)}") return code, teachers = _req("GET", "/api/teachers?size=20", token) must("list teachers", code, teachers) teacher_ids = [t["id"] for t in (teachers.get("items") or teachers.get("data") or [])][:3] if len(teacher_ids) < 1: print(" SKIP need at least 1 teacher in scope") return code, data = _req( "POST", f"/api/classrooms/{classroom_id}/students", token, {"student_ids": student_ids, "mode": "set"}, ) must(f"set classroom roster ({len(student_ids)} students)", code, data) code, data = _req( "POST", f"/api/classrooms/{classroom_id}/teachers", token, {"teacher_ids": teacher_ids, "mode": "set"}, ) must(f"set homeroom teachers ({len(teacher_ids)})", code, data) # Step 3: assign a course + section code, courses = _req("GET", "/api/courses?size=20", token) must("list courses", code, courses) course_items = courses.get("items") or courses.get("data") or [] if not course_items: print(" SKIP no courses in scope") return course_id = course_items[0]["id"] code, secs = _req("GET", f"/api/courses/{course_id}/sections", token) must("list course sections", code, secs) section_items = secs.get("items") or secs.get("data") or [] section_id = section_items[0]["id"] if section_items else None payload = {"course_id": course_id, "term_key": "2026-SMOKE"} if section_id: payload["section_id"] = section_id code, data = _req( "POST", f"/api/classrooms/{classroom_id}/assign-course", token, payload, ) must("assign course (cascade)", code, data) batch = data.get("batch") or {} batch_id = batch.get("id") if not batch_id: print(f" FAIL no batch returned: {data}") sys.exit(1) print( f" -> created batch #{batch_id} '{batch.get('name')}' " f"with {batch.get('student_count')} student(s)" ) # Step 4: verify the cascade actually populated the batch code, batch_students = _req("GET", f"/api/batches/{batch_id}/students", token) must("get batch students", code, batch_students) enrolled = [s["id"] for s in (batch_students.get("data") or [])] print(f" -> batch enrolled students: {len(enrolled)}") code, batch_teachers = _req("GET", f"/api/batches/{batch_id}/teachers", token) must("get batch teachers", code, batch_teachers) enrolled_teachers = [t["id"] for t in (batch_teachers.get("items") or [])] print(f" -> batch teachers: {enrolled_teachers}") # Step 5: override the batch -> remove one student, set a single teacher drop_id = student_ids[0] code, data = _req( "POST", f"/api/batches/{batch_id}/students/remove", token, {"student_ids": [drop_id]}, ) must(f"remove student {drop_id} from batch", code, data) keep_teacher = teacher_ids[0] code, data = _req( "POST", f"/api/batches/{batch_id}/teachers", token, {"teacher_ids": [keep_teacher], "mode": "set"}, ) must(f"set batch teachers -> [{keep_teacher}]", code, data) # Step 6: re-check code, batch_students = _req("GET", f"/api/batches/{batch_id}/students", token) must("re-get batch students", code, batch_students) enrolled_after = {s["id"] for s in (batch_students.get("data") or [])} if drop_id in enrolled_after: print(f" FAIL student {drop_id} still in batch after remove: {enrolled_after}") sys.exit(1) code, batch_teachers = _req("GET", f"/api/batches/{batch_id}/teachers", token) must("re-get batch teachers", code, batch_teachers) teachers_after = [t["id"] for t in (batch_teachers.get("items") or [])] if teachers_after != [keep_teacher]: print( f" FAIL batch teachers expected [{keep_teacher}], got {teachers_after}" ) sys.exit(1) print("\nAll assignment workflow steps passed ✓") if __name__ == "__main__": main()