Spaces:
Sleeping
Sleeping
| """ | |
| End-to-end API test script. | |
| Tests every major endpoint against a running instance. | |
| Environment config is read from env files — no need to memorise CLI args: | |
| test_data/.env.test — local defaults (safe to commit) | |
| test_data/.env.test.hf — HF Spaces config (gitignored; copy from .env.test.hf.example) | |
| Usage: | |
| # Local (default — reads test_data/.env.test) | |
| uv run python test_data/test_api.py --run | |
| uv run python test_data/test_api.py --run --smoke | |
| uv run python test_data/test_api.py --run --skip-sync | |
| uv run python test_data/test_api.py --clean-db | |
| # HF Spaces (reads test_data/.env.test.hf) | |
| uv run python test_data/test_api.py --env hf --run | |
| uv run python test_data/test_api.py --env hf --run --smoke | |
| Requires: requests (pip install requests or uv add requests) | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from datetime import date, datetime, timedelta | |
| try: | |
| import requests | |
| except ImportError: | |
| sys.exit("Install requests first: pip install requests") | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| ROOT = os.path.dirname(HERE) | |
| LOGS = os.path.join(ROOT, "logs") | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| YELLOW= "\033[93m" | |
| BOLD = "\033[1m" | |
| RESET = "\033[0m" | |
| class _Tee: | |
| """Write to both stdout and a log file, stripping ANSI codes for the file.""" | |
| _ANSI_STRIP = __import__("re").compile(r"\x1b\[[0-9;]*m") | |
| def __init__(self, log_path: str): | |
| self._file = open(log_path, "a", encoding="utf-8") | |
| self._stdout = open(sys.__stdout__.fileno(), "w", | |
| encoding="utf-8", errors="replace", closefd=False) | |
| def write(self, text: str): | |
| self._stdout.write(text) | |
| self._file.write(self._ANSI_STRIP.sub("", text)) | |
| self._file.flush() | |
| def flush(self): | |
| self._stdout.flush() | |
| self._file.flush() | |
| def close(self): | |
| self._file.close() | |
| self._stdout.close() | |
| def encoding(self): | |
| return "utf-8" | |
| def fileno(self): | |
| return sys.__stdout__.fileno() | |
| # ── Helpers ──────────────────────────────────────────────────────────────────── | |
| passed = failed = skipped = 0 | |
| def _fmt(label: str, width: int = 60) -> str: | |
| return label.ljust(width) | |
| def ok(label: str, detail: str = ""): | |
| global passed | |
| passed += 1 | |
| suffix = f" {YELLOW}{detail}{RESET}" if detail else "" | |
| print(f" {GREEN}PASS{RESET} {_fmt(label)}{suffix}") | |
| def fail(label: str, reason: str): | |
| global failed | |
| failed += 1 | |
| print(f" {RED}FAIL{RESET} {_fmt(label)} {RED}{reason}{RESET}") | |
| def skip(label: str, reason: str = ""): | |
| global skipped | |
| skipped += 1 | |
| suffix = f" ({reason})" if reason else "" | |
| print(f" {YELLOW}SKIP{RESET} {_fmt(label)}{suffix}") | |
| def section(title: str): | |
| print(f"\n{BOLD}{'-'*66}{RESET}") | |
| print(f"{BOLD} {title}{RESET}") | |
| print(f"{BOLD}{'-'*66}{RESET}") | |
| # ── .env bootstrap — load before argparse so defaults pick up .env values ────── | |
| def _load_dotenv(path: str = None) -> dict: | |
| """Parse a .env file and return its key-value pairs.""" | |
| env_path = path or os.path.join(ROOT, ".env") | |
| result: dict = {} | |
| if not os.path.exists(env_path): | |
| return result | |
| with open(env_path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| k, _, v = line.partition("=") | |
| result[k.strip()] = v.strip() | |
| return result | |
| def _resolve_test_env_flavor() -> str: | |
| """Quick scan of sys.argv to determine which test env file to load (before argparse).""" | |
| for i, arg in enumerate(sys.argv): | |
| if arg == "--env" and i + 1 < len(sys.argv): | |
| return sys.argv[i + 1] | |
| return "local" | |
| # 1. Load root .env (base config) — never overrides real env vars | |
| _dotenv_early = _load_dotenv() | |
| for _k, _v in _dotenv_early.items(): | |
| os.environ.setdefault(_k, _v) | |
| # 2. Load test env file on top (overrides root .env test-specific keys) | |
| _flavor = _resolve_test_env_flavor() | |
| _test_env_file = os.path.join(HERE, ".env.test.hf" if _flavor == "hf" else ".env.test") | |
| _dotenv_test = _load_dotenv(_test_env_file) | |
| for _k, _v in _dotenv_test.items(): | |
| os.environ[_k] = _v # test env takes precedence over root .env | |
| # ── Auth token state ─────────────────────────────────────────────────────────── | |
| _token: str | None = None # app JWT — set after OTP login | |
| _hf_token: str | None = None # HF Space token — bypasses HF proxy auth | |
| # Admin email resolution (priority order): | |
| # 1. TEST_ADMIN_EMAIL in test env file (.env.test / .env.test.hf) | |
| # 2. ADMIN_EMAILS in root .env (first entry — the server's own admin list) | |
| _admin_email: str = ( | |
| os.environ.get("TEST_ADMIN_EMAIL") | |
| or os.environ.get("ADMIN_EMAILS", "").split(",")[0].strip() | |
| or "admin@geeksforgeeks.org" | |
| ) | |
| _is_local: bool = os.environ.get("IS_LOCAL", "true").lower() == "true" | |
| def _headers(*, include_jwt: bool = True) -> dict: | |
| h: dict = {} | |
| if include_jwt and _token: | |
| # Authenticated app request — JWT goes in Authorization. | |
| # HF proxy accepts any bearer token for public spaces; | |
| # for private spaces the JWT won't satisfy HF auth, | |
| # but there's no way to send two Bearer tokens simultaneously. | |
| h["Authorization"] = f"Bearer {_token}" | |
| elif _hf_token: | |
| # No JWT yet (pre-login) — send HF token so HF proxy lets the request through. | |
| h["Authorization"] = f"Bearer {_hf_token}" | |
| return h | |
| def get(base: str, path: str, params: dict = None) -> requests.Response | None: | |
| try: | |
| return requests.get(f"{base}{path}", params=params, headers=_headers(), timeout=30) | |
| except Exception as exc: | |
| print(f" connection error: {exc}") | |
| return None | |
| def post(base: str, path: str, body, *, auth: bool = True, extra_headers: dict = None) -> requests.Response | None: | |
| try: | |
| h = _headers(include_jwt=auth) | |
| if extra_headers: | |
| h.update(extra_headers) | |
| return requests.post(f"{base}{path}", json=body, headers=h, timeout=60) | |
| except Exception as exc: | |
| print(f" connection error: {exc}") | |
| return None | |
| def delete(base: str, path: str, extra_headers: dict = None) -> requests.Response | None: | |
| try: | |
| h = _headers() | |
| if extra_headers: | |
| h.update(extra_headers) | |
| return requests.delete(f"{base}{path}", headers=h, timeout=10) | |
| except Exception as exc: | |
| print(f" connection error: {exc}") | |
| return None | |
| def patch(base: str, path: str) -> requests.Response | None: | |
| try: | |
| return requests.patch(f"{base}{path}", headers=_headers(), timeout=10) | |
| except Exception as exc: | |
| print(f" connection error: {exc}") | |
| return None | |
| def check(label: str, r: requests.Response | None, *, status: int = 200, | |
| contains_key: str = None, min_len: int = None): | |
| if r is None: | |
| fail(label, "connection error") | |
| return None | |
| if r.status_code != status: | |
| fail(label, f"HTTP {r.status_code}: {r.text[:120]}") | |
| return None | |
| try: | |
| data = r.json() | |
| except Exception: | |
| fail(label, "response is not JSON") | |
| return None | |
| if contains_key and contains_key not in (data if isinstance(data, dict) else {}): | |
| fail(label, f"missing key '{contains_key}' in response") | |
| return None | |
| if min_len is not None and len(data) < min_len: | |
| fail(label, f"expected >= {min_len} items, got {len(data)}") | |
| return None | |
| detail = "" | |
| if isinstance(data, list): | |
| detail = f"{len(data)} items" | |
| elif isinstance(data, dict) and contains_key: | |
| detail = f"{contains_key}={data[contains_key]}" | |
| ok(label, detail) | |
| return data | |
| # ── Tests ────────────────────────────────────────────────────────────────────── | |
| def test_auth(base: str) -> bool: | |
| """ | |
| Test OTP login flow using default OTP (IS_LOCAL=true mode). | |
| Stores token in global _token so all subsequent requests are authenticated. | |
| """ | |
| global _token | |
| section("Authentication — OTP login flow") | |
| # 1. Request OTP | |
| r = post(base, "/api/v1/auth/request-otp", | |
| {"email": _admin_email, "channel": "email"}, auth=False) | |
| data = check("POST /api/v1/auth/request-otp", r, contains_key="message") | |
| if not data: | |
| fail("Auth flow aborted", "Could not request OTP") | |
| return False | |
| # 2. Verify with default OTP (123456 in IS_LOCAL mode) | |
| r2 = post(base, "/api/v1/auth/verify-otp", | |
| {"email": _admin_email, "otp": "123456"}, auth=False) | |
| token_data = check("POST /api/v1/auth/verify-otp", r2, contains_key="access_token") | |
| if not token_data: | |
| fail("Auth flow aborted", "OTP verification failed — is IS_LOCAL=true set?") | |
| return False | |
| _token = token_data["access_token"] | |
| if token_data.get("role") == "admin": | |
| ok(" role=admin confirmed in token response") | |
| else: | |
| fail(" role=admin in token", f"got role={token_data.get('role')}") | |
| # 3. GET /auth/me | |
| r3 = get(base, "/api/v1/auth/me") | |
| me = check("GET /api/v1/auth/me", r3, contains_key="email") | |
| if me: | |
| if me["email"] == _admin_email.lower(): | |
| ok(" /auth/me returns correct email") | |
| if me["role"] == "admin": | |
| ok(" /auth/me returns role=admin") | |
| # 4. Unauthenticated request should return 401 | |
| try: | |
| r4 = requests.get(f"{base}/api/v1/stats", timeout=10) | |
| except Exception: | |
| r4 = None | |
| if r4 is not None and r4.status_code == 401: | |
| ok("GET /api/v1/stats without token → 401") | |
| else: | |
| fail("GET /api/v1/stats without token → 401", | |
| f"got HTTP {r4.status_code if r4 is not None else 'unreachable'}") | |
| # 5. Invalid OTP should return 401 | |
| r5 = post(base, "/api/v1/auth/verify-otp", | |
| {"email": _admin_email, "otp": "000000"}, auth=False) | |
| if r5 is not None and r5.status_code == 401: | |
| ok("POST /api/v1/auth/verify-otp with wrong OTP → 401") | |
| else: | |
| fail("POST /api/v1/auth/verify-otp with wrong OTP → 401", | |
| f"got HTTP {r5.status_code if r5 is not None else 'unreachable'}") | |
| return _token is not None | |
| def test_soft_delete_audit(base: str): | |
| """Create, soft-delete, verify in audit list, then hard-delete a room.""" | |
| section("Soft-delete + delete audit") | |
| # Create a room | |
| r = post(base, "/api/v1/rooms", {"name": "_audit_test_room", "capacity": 5}) | |
| room = check("POST /api/v1/rooms (create for audit test)", r, status=201, contains_key="id") | |
| if not room: | |
| return | |
| # Soft-delete it | |
| r2 = delete(base, f"/api/v1/rooms/{room['id']}") | |
| if r2 and r2.ok: | |
| ok(f"DELETE /api/v1/rooms/{{id}} (soft-delete)") | |
| else: | |
| fail(f"DELETE /api/v1/rooms/{{id}}", f"HTTP {r2.status_code if r2 else 'err'}") | |
| return | |
| # Verify it's gone from list | |
| r3 = get(base, "/api/v1/rooms") | |
| if r3 and r3.ok: | |
| ids = [x["id"] for x in r3.json()] | |
| if room["id"] not in ids: | |
| ok(" Room not in active list after soft-delete") | |
| else: | |
| fail(" Room not in active list after soft-delete", "still visible") | |
| # Verify it appears in deleted audit | |
| r4 = get(base, "/api/v1/admin/deleted", {"entity": "rooms"}) | |
| audit = check("GET /api/v1/admin/deleted?entity=rooms", r4, contains_key="records") | |
| if audit: | |
| deleted_ids = [x["id"] for x in audit["records"]] | |
| if room["id"] in deleted_ids: | |
| ok(" Deleted room appears in audit list") | |
| else: | |
| fail(" Deleted room in audit list", "not found") | |
| # Hard-delete it | |
| r5 = delete(base, f"/api/v1/admin/deleted/rooms/{room['id']}", | |
| extra_headers={"X-Confirm": "harddelete"}) | |
| if r5 and r5.ok: | |
| ok(f"DELETE /api/v1/admin/deleted/rooms/{{id}} (hard-delete)") | |
| else: | |
| fail(f"Hard-delete room", f"HTTP {r5.status_code if r5 else 'err'}: {r5.text[:80] if r5 else ''}") | |
| # Confirm it's gone from audit too | |
| r6 = get(base, "/api/v1/admin/deleted", {"entity": "rooms"}) | |
| if r6 and r6.ok: | |
| deleted_ids2 = [x["id"] for x in r6.json().get("records", [])] | |
| if room["id"] not in deleted_ids2: | |
| ok(" Room no longer in audit list after hard-delete") | |
| else: | |
| fail(" Room removed from audit after hard-delete", "still present") | |
| def test_me_dashboard(base: str): | |
| """Test /me/dashboard returns role-appropriate data.""" | |
| section("Me Dashboard") | |
| data = check("GET /api/v1/me/dashboard", get(base, "/api/v1/me/dashboard"), | |
| contains_key="role") | |
| if data: | |
| ok(f" role={data['role']} in dashboard response") | |
| if data["role"] == "admin" and "stats" in data: | |
| ok(" admin dashboard has stats block") | |
| def test_health(base: str): | |
| section("Health / UI") | |
| r = get(base, "/") | |
| if r is None or r.status_code != 200: | |
| fail("GET / (dashboard loads)", f"HTTP {r.status_code if r else 'unreachable'}") | |
| else: | |
| ok("GET / (dashboard loads)") | |
| data = check("GET /api/v1/stats", get(base, "/api/v1/stats"), | |
| contains_key="mentors") | |
| if data: | |
| # Verify all expected keys are present | |
| for key in ("mentors", "students", "batches", "sessions_total", | |
| "sessions_this_week", "week_start", "week_end"): | |
| if key not in data: | |
| fail(f" stats.{key} present", f"missing key {key}") | |
| else: | |
| ok(f" stats.{key} present", str(data[key])) | |
| def test_courses(base: str) -> list[dict]: | |
| section("Courses") | |
| data = check("GET /api/v1/courses", get(base, "/api/v1/courses"), min_len=0) | |
| return data or [] | |
| def test_rooms(base: str) -> list[dict]: | |
| section("Rooms") | |
| data = check("GET /api/v1/rooms", get(base, "/api/v1/rooms"), min_len=0) | |
| return data or [] | |
| def test_mentors(base: str) -> list[dict]: | |
| section("Mentors") | |
| data = check("GET /api/v1/mentors", get(base, "/api/v1/mentors"), min_len=0) | |
| if data: | |
| mid = data[0]["id"] | |
| mentor = check(f"GET /api/v1/mentors/{{id}}", get(base, f"/api/v1/mentors/{mid}"), | |
| contains_key="id") | |
| if mentor: | |
| for field in ("email", "mobile_number", "off_days"): | |
| if field in mentor: | |
| ok(f" mentor.{field} in response") | |
| else: | |
| fail(f" mentor.{field} in response", "field missing from response") | |
| # Detail endpoint — includes batches + courses | |
| detail = check(f"GET /api/v1/mentors/{{id}}/details", | |
| get(base, f"/api/v1/mentors/{mid}/details"), | |
| contains_key="id") | |
| if detail: | |
| for field in ("email", "mobile_number", "off_days", "courses", "batches"): | |
| if field in detail: | |
| ok(f" mentor detail.{field} present") | |
| else: | |
| fail(f" mentor detail.{field} present", "field missing") | |
| # Sessions filtered by mentor_id | |
| check(f"GET /api/v1/sessions?mentor_id={{id}}", | |
| get(base, "/api/v1/sessions", {"mentor_id": mid}), | |
| min_len=0) | |
| return data or [] | |
| def test_students(base: str) -> list[dict]: | |
| section("Students") | |
| data = check("GET /api/v1/students", get(base, "/api/v1/students"), min_len=0) | |
| if data: | |
| sid = data[0]["id"] | |
| student = check(f"GET /api/v1/students/{{id}}", get(base, f"/api/v1/students/{sid}"), | |
| contains_key="id") | |
| if student: | |
| for field in ("email", "mobile_number"): | |
| if field in student: | |
| ok(f" student.{field} in response") | |
| else: | |
| fail(f" student.{field} in response", "field missing from response") | |
| # Detail endpoint — includes batches list | |
| detail = check(f"GET /api/v1/students/{{id}}/details", | |
| get(base, f"/api/v1/students/{sid}/details"), | |
| contains_key="id") | |
| if detail: | |
| for field in ("email", "mobile_number", "batches"): | |
| if field in detail: | |
| ok(f" student detail.{field} present") | |
| else: | |
| fail(f" student detail.{field} present", "field missing") | |
| # Sessions filtered by student_id | |
| check(f"GET /api/v1/sessions?student_id={{id}}", | |
| get(base, "/api/v1/sessions", {"student_id": sid}), | |
| min_len=0) | |
| return data or [] | |
| def test_batches(base: str) -> list[dict]: | |
| section("Batches") | |
| data = check("GET /api/v1/batches", get(base, "/api/v1/batches"), min_len=0) | |
| if data: | |
| bid = data[0]["id"] | |
| check(f"GET /api/v1/batches/{{id}}", get(base, f"/api/v1/batches/{bid}"), | |
| contains_key="id") | |
| return data or [] | |
| def test_sync(base: str) -> bool: | |
| section("Ingest / Sync") | |
| payload_path = os.path.join(HERE, "sync_payload.json") | |
| if not os.path.exists(payload_path): | |
| skip("POST /api/v1/ingest/sync", "sync_payload.json not found") | |
| return False | |
| with open(payload_path) as f: | |
| payload = json.load(f) | |
| r = post(base, "/api/v1/ingest/sync", payload) | |
| data = check("POST /api/v1/ingest/sync (upsert all entities)", r, | |
| status=200, contains_key="upserted") | |
| if data: | |
| u = data["upserted"] | |
| print(f" upserted → courses:{u.get('courses',0)} rooms:{u.get('rooms',0)} " | |
| f"mentors:{u.get('mentors',0)} students:{u.get('students',0)} batches:{u.get('batches',0)}") | |
| if data.get("errors"): | |
| print(f" {YELLOW}sync errors: {data['errors'][:3]}{RESET}") | |
| return data is not None | |
| def test_enroll(base: str): | |
| section("Enroll students") | |
| enroll_path = os.path.join(HERE, "enroll_requests.json") | |
| if not os.path.exists(enroll_path): | |
| skip("Enrollment", "enroll_requests.json not found") | |
| return | |
| with open(enroll_path) as f: | |
| enroll_map: dict[str, list[str]] = json.load(f) | |
| ok_count = err_count = 0 | |
| for batch_id, student_ids in enroll_map.items(): | |
| r = post(base, f"/api/v1/batches/{batch_id}/enroll", {"student_ids": student_ids}) | |
| if r is not None and r.ok: | |
| ok_count += 1 | |
| else: | |
| err_count += 1 | |
| status = r.status_code if r else "unreachable" | |
| body = r.text[:80] if r else "" | |
| print(f" {YELLOW}warn{RESET} {batch_id} HTTP {status} {body}") | |
| label = f"Enroll all batches ({len(enroll_map)} batches)" | |
| if err_count == 0: | |
| ok(label, f"{sum(len(v) for v in enroll_map.values())} student-batch links") | |
| else: | |
| fail(label, f"{err_count} batches failed") | |
| def test_schedule(base: str, smoke: bool): | |
| section("Schedule – auto-generate + confirm") | |
| schedule_path = os.path.join(HERE, "schedule_requests.json") | |
| if not os.path.exists(schedule_path): | |
| skip("Schedule generation", "schedule_requests.json not found") | |
| return | |
| with open(schedule_path) as f: | |
| requests_list: list[dict] = json.load(f) | |
| to_test = requests_list[:1] if smoke else requests_list | |
| confirmed_total = 0 | |
| for req in to_test: | |
| bid = req["batch_id"] | |
| r = post(base, "/api/v1/schedule/auto-generate", req) | |
| label = f"auto-generate {bid}" | |
| if r is None or not r.ok: | |
| fail(label, f"HTTP {r.status_code if r else 'unreachable'}: {r.text[:80] if r else ''}") | |
| continue | |
| proposals = r.json().get("placed", []) | |
| ok(label, f"{len(proposals)} proposals") | |
| if not proposals: | |
| skip(f"confirm {bid}", "no proposals to confirm") | |
| continue | |
| r2 = post(base, "/api/v1/schedule/confirm", {"proposals": proposals}) | |
| label2 = f"confirm {bid}" | |
| data2 = check(label2, r2, status=201, contains_key="created") | |
| if data2: | |
| confirmed_total += data2.get("created", 0) | |
| if not smoke: | |
| print(f" total sessions confirmed: {confirmed_total}") | |
| def test_calendar(base: str, students: list[dict], mentors: list[dict]): | |
| section("Calendar") | |
| today = date.today() | |
| monday = (today - timedelta(days=today.weekday())).isoformat() | |
| if students: | |
| sid = students[0]["id"] | |
| check(f"GET /api/v1/calendar/student/{{id}}", | |
| get(base, f"/api/v1/calendar/student/{sid}", {"week_start": monday}), | |
| min_len=0) | |
| if mentors: | |
| mid = mentors[0]["id"] | |
| check(f"GET /api/v1/calendar/mentor/{{id}}", | |
| get(base, f"/api/v1/calendar/mentor/{mid}", {"week_start": monday}), | |
| min_len=0) | |
| check("GET /api/v1/calendar/week", | |
| get(base, "/api/v1/calendar/week", {"week_start": monday}), | |
| min_len=0) | |
| def test_conflicts(base: str): | |
| """ | |
| Conflict detection — what it catches and when it matters | |
| -------------------------------------------------------- | |
| The auto-generate solver prevents scheduling conflicts when creating new | |
| sessions — it checks the occupation map before placing each slot. | |
| So why does a conflict checker exist at all? | |
| Three real situations where conflicts slip in despite using the tool: | |
| 1. MID-COURSE STUDENT ENROLLMENT | |
| The solver runs once at the start of a term. If a student is enrolled | |
| into Batch-B two weeks later, and Batch-B's sessions overlap with | |
| Batch-A (which the student is already in), the solver never re-ran | |
| to catch this. The conflict checker finds it. | |
| → Admin must unenroll the student from one batch or move a session. | |
| 2. MANUAL SESSION CREATION / EDIT | |
| Admin can create or edit sessions directly via the CRUD endpoints | |
| (POST /sessions, PUT /sessions/{id}). These endpoints do NOT call the | |
| solver — they just write to DB. An admin assigning a room that's already | |
| in use, or creating a makeup class for a mentor who already has another | |
| batch that hour, creates a real conflict. | |
| → Conflict checker surfaces it; admin fixes room or reschedules. | |
| 3. BULK INGEST (CSV import) | |
| /ingest/sync accepts raw session data without running the solver. | |
| Historical data imported from a spreadsheet may have duplicates or | |
| overlaps that were fine in the old system but are flagged here. | |
| → Run all_time=true after every ingest to audit the full dataset. | |
| Typical admin workflow | |
| ---------------------- | |
| Dashboard loads GET /conflicts (current week) — badge shows count. | |
| Admin opens detail view, sees e.g. "student Priya: batch-1 and batch-2 | |
| both at slot-2 on Thu 10 Apr". Admin resolves (unenroll / reschedule), | |
| re-runs check to confirm zero conflicts. | |
| GET /conflicts?all_time=true is the periodic audit tool (run before a | |
| new term or after bulk ingest). | |
| """ | |
| section("Conflict detection") | |
| # ── 1. Current-week endpoint — basic shape ───────────────────────────── | |
| data = check("GET /api/v1/conflicts (current week)", | |
| get(base, "/api/v1/conflicts"), | |
| contains_key="conflicts") | |
| if data is not None: | |
| assert isinstance(data.get("total"), int), "total must be int" | |
| assert isinstance(data.get("conflicts"), list), "conflicts must be list" | |
| ok(" response shape: total + conflicts[]") | |
| # Each conflict item must have required fields | |
| for item in data["conflicts"][:3]: # spot-check first 3 | |
| for field in ("date", "slot_id", "slot_start", "slot_end", | |
| "conflict_type", "entity_id", "entity_name", "session_ids"): | |
| if field not in item: | |
| fail(f" conflict item missing field={field}", "field absent") | |
| break | |
| else: | |
| ok(f" conflict item shape OK (type={item.get('conflict_type')})") | |
| # conflict_type must be one of the three known types | |
| bad_types = [c["conflict_type"] for c in data["conflicts"] | |
| if c.get("conflict_type") not in ("mentor", "room", "student")] | |
| if bad_types: | |
| fail(" conflict_type values", f"unexpected types: {bad_types}") | |
| else: | |
| ok(" conflict_type values are valid (mentor|room|student)") | |
| # ── 2. All-time endpoint ──────────────────────────────────────────────── | |
| data_all = check("GET /api/v1/conflicts?all_time=true", | |
| get(base, "/api/v1/conflicts", {"all_time": "true"}), | |
| contains_key="conflicts") | |
| if data_all is not None: | |
| assert isinstance(data_all.get("total"), int) | |
| # all-time must return >= current-week count (superset) | |
| if data is not None and data_all["total"] < data["total"]: | |
| fail(" all_time >= current week", "all_time total is smaller — logic error") | |
| else: | |
| ok(f" all_time total={data_all['total']} >= week total={data['total'] if data else 0}") | |
| # ── 3. Date-range filter ──────────────────────────────────────────────── | |
| past_monday = (date.today() - timedelta(days=date.today().weekday() + 7)).isoformat() | |
| data_range = check(f"GET /api/v1/conflicts?week_start={past_monday} (last week)", | |
| get(base, "/api/v1/conflicts", {"week_start": past_monday}), | |
| contains_key="conflicts") | |
| if data_range is not None: | |
| ok(f" last-week filter returned total={data_range['total']}") | |
| # ── 4. Auth required ─────────────────────────────────────────────────── | |
| import requests as _req | |
| r_noauth = _req.get(f"{base}/api/v1/conflicts", timeout=10) | |
| if r_noauth.status_code == 401: | |
| ok(" unauthenticated request correctly rejected (401)") | |
| else: | |
| fail(" unauthenticated request rejected", f"got {r_noauth.status_code}") | |
| def test_sessions(base: str): | |
| section("Sessions") | |
| data = check("GET /api/v1/sessions", get(base, "/api/v1/sessions"), min_len=0) | |
| if data: | |
| sid = data[0]["id"] | |
| session = check(f"GET /api/v1/sessions/{{id}}", get(base, f"/api/v1/sessions/{sid}"), | |
| contains_key="id") | |
| if session: | |
| # Verify enriched fields are present | |
| for field in ("batch_name", "slot_start", "slot_end"): | |
| if field in session: | |
| ok(f" session.{field} in response") | |
| else: | |
| fail(f" session.{field} in response", "enriched field missing") | |
| # Test lock toggle (PATCH) | |
| r = patch(base, f"/api/v1/sessions/{sid}/lock") | |
| if r is not None and r.ok: | |
| ok(f"PATCH /api/v1/sessions/{{id}}/lock") | |
| locked_status = r.json().get("status") | |
| if locked_status in ("Locked", "Confirmed"): | |
| ok(f" lock toggle returned status={locked_status}") | |
| else: | |
| fail(f" lock toggle status", f"unexpected status: {locked_status}") | |
| # Toggle back | |
| patch(base, f"/api/v1/sessions/{sid}/lock") | |
| else: | |
| fail(f"PATCH /api/v1/sessions/{{id}}/lock", | |
| f"HTTP {r.status_code if r else 'unreachable'}") | |
| def test_settings(base: str): | |
| section("Settings") | |
| data = check("GET /api/v1/settings/config", get(base, "/api/v1/settings/config"), | |
| contains_key="Server") | |
| if data: | |
| ok(" settings grouped by section", f"{len(data)} sections") | |
| def test_slot_validation(base: str): | |
| """Ensure invalid slot_id is rejected at the schema layer.""" | |
| section("Validation — slot_id guard") | |
| # We need a valid batch_id; skip if there are no batches | |
| r = get(base, "/api/v1/batches") | |
| if r is None or not r.ok or not r.json(): | |
| skip("POST /api/v1/sessions (invalid slot_id)", "no batches available") | |
| return | |
| batch_id = r.json()[0]["id"] | |
| today = date.today().isoformat() | |
| body = {"batch_id": batch_id, "date": today, "slot_id": 9} # 9 is invalid | |
| r2 = post(base, "/api/v1/sessions", body) | |
| if r2 is not None and r2.status_code == 422: | |
| ok("POST /api/v1/sessions with slot_id=9 → 422 Unprocessable") | |
| else: | |
| fail("POST /api/v1/sessions with slot_id=9 → 422 Unprocessable", | |
| f"HTTP {r2.status_code if r2 else 'unreachable'}") | |
| def test_attendance(base: str, students: list[dict], sessions: list[dict]): | |
| """ | |
| Attendance module — device registration, dev-simulate, QR, manual mark, records. | |
| Uses: | |
| - dev-simulate to mark attendance without a physical phone or open QR window | |
| - DEFAULT_OTP (123456) for device registration in IS_LOCAL mode | |
| Requires IS_LOCAL=true. | |
| """ | |
| section("Attendance") | |
| if not sessions: | |
| skip("Attendance tests", "no sessions available") | |
| return | |
| session_id = sessions[0]["id"] | |
| batch_id = sessions[0].get("batch_id", "") | |
| # ── 1. Device registration ───────────────────────────────────────────────── | |
| if students: | |
| student_mobile = students[0].get("mobile_number", "") | |
| student_id = students[0]["id"] | |
| if student_mobile: | |
| # register-device: always returns 200 regardless of whether number exists | |
| r = post(base, "/api/v1/attendance/register-device", | |
| {"mobile_number": student_mobile}, auth=False) | |
| check("POST /api/v1/attendance/register-device", r, contains_key="message") | |
| # verify-device with DEFAULT_OTP | |
| r2 = post(base, "/api/v1/attendance/verify-device", | |
| {"mobile_number": student_mobile, "otp": "123456"}, auth=False) | |
| device_data = check("POST /api/v1/attendance/verify-device (DEFAULT_OTP=123456)", | |
| r2, contains_key="device_token") | |
| if device_data: | |
| ok(" device_token issued", f"expires_days={device_data.get('expires_days')}") | |
| device_token = device_data["device_token"] | |
| # device-status check | |
| import requests as _req | |
| r3 = _req.get(f"{base}/api/v1/attendance/device-status", | |
| headers={"X-Device-Token": device_token}, timeout=10) | |
| ds = check("GET /api/v1/attendance/device-status (with token)", r3, | |
| contains_key="registered") | |
| if ds and ds.get("registered"): | |
| ok(" device registered=true confirmed") | |
| elif ds: | |
| fail(" device registered=true confirmed", f"registered={ds.get('registered')}") | |
| else: | |
| ok(" (device token test skipped — OTP may not match in non-local mode)") | |
| else: | |
| skip("Device registration", "student has no mobile_number") | |
| else: | |
| skip("Device registration", "no students available") | |
| # ── 2. QR endpoint (may be outside class window — both outcomes are valid) ── | |
| r = post(base, f"/api/v1/attendance/sessions/{session_id}/qr", None) | |
| # Reuse get() since QR is a GET | |
| r_qr = get(base, f"/api/v1/attendance/sessions/{session_id}/qr") | |
| if r_qr is None: | |
| fail("GET /api/v1/attendance/sessions/{id}/qr", "connection error") | |
| elif r_qr.status_code == 200: | |
| qr_data = r_qr.json() | |
| for field in ("token", "qr_image_b64", "scan_url", "expires_in_seconds"): | |
| if field in qr_data: | |
| ok(f" qr.{field} present") | |
| else: | |
| fail(f" qr.{field} present", "field missing") | |
| ok("GET /api/v1/attendance/sessions/{id}/qr → 200 (within window)") | |
| elif r_qr.status_code == 403: | |
| ok("GET /api/v1/attendance/sessions/{id}/qr → 403 (outside window — expected)") | |
| else: | |
| fail("GET /api/v1/attendance/sessions/{id}/qr", | |
| f"HTTP {r_qr.status_code}: {r_qr.text[:80]}") | |
| # ── 3. Summary endpoint ──────────────────────────────────────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{session_id}/summary") | |
| summary = check("GET /api/v1/attendance/sessions/{id}/summary", r, | |
| contains_key="enrolled_count") | |
| if summary: | |
| for field in ("enrolled_count", "present_count", "late_count", "absent_count"): | |
| if field in summary: | |
| ok(f" summary.{field} present", str(summary[field])) | |
| else: | |
| fail(f" summary.{field} present", "field missing") | |
| # ── 4. Records endpoint (empty before any attendance) ───────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{session_id}/records") | |
| records_before = check("GET /api/v1/attendance/sessions/{id}/records (before simulate)", | |
| r, min_len=0) | |
| # ── 5. Dev simulate — mark students present ──────────────────────────────── | |
| if students: | |
| student_ids = [s["id"] for s in students[:2]] | |
| r = post(base, f"/api/v1/attendance/sessions/{session_id}/dev-simulate", | |
| {"student_ids": student_ids}) | |
| if r is None: | |
| fail("POST /api/v1/attendance/sessions/{id}/dev-simulate", "connection error") | |
| elif r.status_code == 403: | |
| skip("POST /api/v1/attendance/sessions/{id}/dev-simulate", | |
| "403 — only works in IS_LOCAL=true mode") | |
| else: | |
| sim_data = check("POST /api/v1/attendance/sessions/{id}/dev-simulate", | |
| r, contains_key="present_count") | |
| if sim_data: | |
| ok(f" present_count after simulate", str(sim_data.get("present_count", 0))) | |
| # ── 6. Records after simulate ────────────────────────────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{session_id}/records") | |
| records_after = check("GET /api/v1/attendance/sessions/{id}/records (after simulate)", | |
| r, min_len=0) | |
| if records_after is not None: | |
| manual_flags = [rec.get("is_manual") for rec in records_after] | |
| if all(manual_flags): | |
| ok(" all simulate records have is_manual=true") | |
| elif records_after: | |
| fail(" simulate records is_manual flag", f"flags={manual_flags}") | |
| # ── 7. Manual mark ───────────────────────────────────────────────────── | |
| entries = [{"student_id": students[0]["id"], "status": "late"}] | |
| if len(students) > 1: | |
| entries.append({"student_id": students[1]["id"], "status": "absent"}) | |
| r = post(base, f"/api/v1/attendance/sessions/{session_id}/mark", | |
| {"entries": entries}) | |
| mark_data = check("POST /api/v1/attendance/sessions/{id}/mark (manual override)", r, | |
| contains_key="present_count") | |
| if mark_data: | |
| ok(f" summary after manual mark: present={mark_data.get('present_count')} " | |
| f"late={mark_data.get('late_count')} absent={mark_data.get('absent_count')}") | |
| # ── 8. Student history ───────────────────────────────────────────────── | |
| sid = students[0]["id"] | |
| r = get(base, f"/api/v1/attendance/students/{sid}/records") | |
| student_recs = check("GET /api/v1/attendance/students/{id}/records", r, min_len=0) | |
| if student_recs is not None: | |
| ok(f" student history records", str(len(student_recs))) | |
| else: | |
| skip("Dev simulate + manual mark + student history", "no students available") | |
| # ── 9. Invalid session ID → 404 ─────────────────────────────────────────── | |
| r = get(base, "/api/v1/attendance/sessions/nonexistent-id-000/summary") | |
| if r is not None and r.status_code == 404: | |
| ok("GET /api/v1/attendance/sessions/bad-id/summary → 404") | |
| else: | |
| fail("GET /api/v1/attendance/sessions/bad-id/summary → 404", | |
| f"HTTP {r.status_code if r else 'unreachable'}") | |
| def test_crud_create_delete(base: str): | |
| """Quick create-then-delete round-trip for each entity, including new fields.""" | |
| section("CRUD round-trips (create + delete)") | |
| # Mentor — include email / mobile_number / off_days fields | |
| r = post(base, "/api/v1/mentors", { | |
| "name": "_test_mentor", | |
| "email": "test.mentor@example.com", | |
| "mobile_number": "+91 99999 00000", | |
| "off_days": ["Sun"], | |
| }) | |
| m = check("POST /api/v1/mentors (create)", r, status=201, contains_key="id") | |
| if m: | |
| # Verify new fields come back in the response | |
| if m.get("email") == "test.mentor@example.com": | |
| ok(" mentor.email round-trips correctly") | |
| else: | |
| fail(" mentor.email round-trips correctly", f"got: {m.get('email')}") | |
| if m.get("mobile_number") == "+91 99999 00000": | |
| ok(" mentor.mobile_number round-trips correctly") | |
| else: | |
| fail(" mentor.mobile_number round-trips correctly", f"got: {m.get('mobile_number')}") | |
| rd = delete(base, f"/api/v1/mentors/{m['id']}") | |
| if rd and rd.ok: | |
| ok(f"DELETE /api/v1/mentors/{{id}} (soft-delete)") | |
| else: | |
| fail(f"DELETE /api/v1/mentors/{{id}}", f"HTTP {rd.status_code if rd else 'err'}") | |
| # Student — include new email / mobile_number fields | |
| r = post(base, "/api/v1/students", { | |
| "name": "_test_student", | |
| "email": "test.student@example.com", | |
| "mobile_number": "+91 88888 00000", | |
| }) | |
| s = check("POST /api/v1/students (create)", r, status=201, contains_key="id") | |
| if s: | |
| if s.get("email") == "test.student@example.com": | |
| ok(" student.email round-trips correctly") | |
| else: | |
| fail(" student.email round-trips correctly", f"got: {s.get('email')}") | |
| if s.get("mobile_number") == "+91 88888 00000": | |
| ok(" student.mobile_number round-trips correctly") | |
| else: | |
| fail(" student.mobile_number round-trips correctly", f"got: {s.get('mobile_number')}") | |
| rd = delete(base, f"/api/v1/students/{s['id']}") | |
| if rd and rd.ok: | |
| ok(f"DELETE /api/v1/students/{{id}} (soft-delete)") | |
| else: | |
| fail(f"DELETE /api/v1/students/{{id}}", f"HTTP {rd.status_code if rd else 'err'}") | |
| # Room — name has unique=True, so hard-delete after soft-delete to allow reruns | |
| r = post(base, "/api/v1/rooms", {"name": "_test_room_xyz", "capacity": 10}) | |
| rm = check("POST /api/v1/rooms (create)", r, status=201, contains_key="id") | |
| if rm: | |
| rd = delete(base, f"/api/v1/rooms/{rm['id']}") | |
| if rd and rd.ok: | |
| ok(f"DELETE /api/v1/rooms/{{id}} (soft-delete)") | |
| # Hard-delete so the unique name is freed for the next test run | |
| rh = delete(base, f"/api/v1/admin/deleted/rooms/{rm['id']}", | |
| extra_headers={"X-Confirm": "harddelete"}) | |
| if rh and rh.ok: | |
| ok(f"DELETE /api/v1/admin/deleted/rooms/{{id}} (hard-delete — free unique name)") | |
| else: | |
| fail(f"DELETE /api/v1/admin/deleted/rooms/{{id}}", f"HTTP {rh.status_code if rh else 'err'}") | |
| else: | |
| fail(f"DELETE /api/v1/rooms/{{id}}", f"HTTP {rd.status_code if rd else 'err'}") | |
| # ── Adhoc attendance test ────────────────────────────────────────────────────── | |
| # Slot windows (Asia/Kolkata UTC+5:30 times as "HH:MM") | |
| _SLOT_WINDOWS = {1: ("09:00", "11:30"), 2: ("11:30", "14:00"), 3: ("14:30", "17:00"), 4: ("17:00", "19:30")} | |
| def _current_slot_id() -> int: | |
| """Return the slot ID whose window includes the current IST time, else 2.""" | |
| now_ist = datetime.utcnow() + timedelta(hours=5, minutes=30) | |
| t = now_ist.strftime("%H:%M") | |
| for slot_id, (start, end) in _SLOT_WINDOWS.items(): | |
| if start <= t <= end: | |
| return slot_id | |
| return 2 # fallback — no running slot; use slot 2 for testing | |
| def test_adhoc_attendance(base: str): | |
| """ | |
| Self-contained test: creates an adhoc batch with a session in the current | |
| (or fallback) slot, enrolls 4 test students, tests manual-mark and | |
| dev-simulate QR attendance, then cleans up all created data. | |
| Requires IS_LOCAL=true for dev-simulate. | |
| """ | |
| section("Adhoc Attendance (self-contained)") | |
| # ── 0. Pre-cleanup: remove any stale "adhoc-test" data from previous runs ── | |
| r_all = get(base, "/api/v1/batches") | |
| if r_all and r_all.ok: | |
| for old_batch in r_all.json(): | |
| if old_batch.get("name") == "adhoc-test": | |
| # Delete sessions for this batch first | |
| r_sess = get(base, f"/api/v1/sessions?batch_id={old_batch['id']}") | |
| if r_sess and r_sess.ok: | |
| for old_sess in r_sess.json(): | |
| delete(base, f"/api/v1/sessions/{old_sess['id']}") | |
| # Unenroll students | |
| for old_st in old_batch.get("students", []): | |
| delete(base, f"/api/v1/batches/{old_batch['id']}/enroll/{old_st['id']}") | |
| delete(base, f"/api/v1/batches/{old_batch['id']}") | |
| # Also clean up any leftover adhoc test students | |
| r_sts = get(base, "/api/v1/students") | |
| if r_sts and r_sts.ok: | |
| for old_st in r_sts.json(): | |
| if (old_st.get("email") or "").startswith("adhoc.student") and old_st["email"].endswith("@example.com"): | |
| delete(base, f"/api/v1/students/{old_st['id']}") | |
| today = date.today().isoformat() | |
| slot_id = _current_slot_id() | |
| slot_start, slot_end = _SLOT_WINDOWS[slot_id] | |
| ok(f"Target slot", f"slot {slot_id} ({slot_start}–{slot_end}) date={today}") | |
| # ── 1. Pick a course and mentor ─────────────────────────────────────────── | |
| r_courses = get(base, "/api/v1/courses") | |
| if r_courses is None or not r_courses.ok: | |
| skip("Adhoc batch test", "cannot reach /courses") | |
| return | |
| courses = r_courses.json() | |
| if not courses: | |
| skip("Adhoc batch test", "no courses in system") | |
| return | |
| r_mentors = get(base, "/api/v1/mentors") | |
| if r_mentors is None or not r_mentors.ok or not r_mentors.json(): | |
| skip("Adhoc batch test", "no mentors in system") | |
| return | |
| mentors = r_mentors.json() | |
| course = courses[0] | |
| mentor = mentors[0] | |
| ok(f"Using course", course.get("title", course["id"])) | |
| ok(f"Using mentor", mentor.get("name", mentor["id"])) | |
| created_student_ids: list[str] = [] | |
| created_batch_id: str | None = None | |
| created_session_id: str | None = None | |
| try: | |
| # ── 2. Create 4 test students ───────────────────────────────────────── | |
| for i in range(1, 5): | |
| r = post(base, "/api/v1/students", { | |
| "name": f"AdhocStudent{i}", | |
| "email": f"adhoc.student{i}.test@example.com", | |
| "mobile_number": f"990000000{i}", | |
| }) | |
| s = check(f"POST /api/v1/students (adhoc student {i})", r, status=201, contains_key="id") | |
| if s: | |
| created_student_ids.append(s["id"]) | |
| if len(created_student_ids) < 4: | |
| fail("Create 4 test students", f"only {len(created_student_ids)} created") | |
| return | |
| # ── 3. Create adhoc batch ───────────────────────────────────────────── | |
| r = post(base, "/api/v1/batches", { | |
| "name": "adhoc-test", | |
| "course_id": course["id"], | |
| "mentor_id": mentor["id"], | |
| "start_date": today, | |
| "status": "Active", | |
| }) | |
| batch = check("POST /api/v1/batches (adhoc)", r, status=201, contains_key="id") | |
| if not batch: | |
| return | |
| created_batch_id = batch["id"] | |
| ok("Adhoc batch created", f"id={created_batch_id}") | |
| # ── 4. Enroll all 4 students ────────────────────────────────────────── | |
| r = post(base, f"/api/v1/batches/{created_batch_id}/enroll", | |
| {"student_ids": created_student_ids}) | |
| if r and r.ok: | |
| ok(f"Enroll 4 students in adhoc batch") | |
| else: | |
| fail("Enroll 4 students", f"HTTP {r.status_code if r else 'err'}: {r.text[:80] if r else ''}") | |
| return | |
| # ── 5. Create a session for today in the current slot ───────────────── | |
| r = post(base, "/api/v1/sessions", { | |
| "batch_id": created_batch_id, | |
| "date": today, | |
| "slot_id": slot_id, | |
| "status": "Confirmed", | |
| }) | |
| sess = check(f"POST /api/v1/sessions (slot {slot_id}, {today})", r, status=201, contains_key="id") | |
| if not sess: | |
| return | |
| created_session_id = sess["id"] | |
| ok("Session created", f"id={created_session_id} slot={slot_id}") | |
| # ── 6. Summary before any attendance ───────────────────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{created_session_id}/summary") | |
| summary = check("GET /api/v1/attendance/sessions/{id}/summary (before)", r, contains_key="enrolled_count") | |
| if summary: | |
| ok(" enrolled_count before", str(summary.get("enrolled_count", 0))) | |
| # ── 7. Manual mark ──────────────────────────────────────────────────── | |
| entries = [ | |
| {"student_id": created_student_ids[0], "status": "present"}, | |
| {"student_id": created_student_ids[1], "status": "late"}, | |
| {"student_id": created_student_ids[2], "status": "absent"}, | |
| ] | |
| r = post(base, f"/api/v1/attendance/sessions/{created_session_id}/mark", {"entries": entries}) | |
| mark_data = check("POST /api/v1/attendance/sessions/{id}/mark (manual)", r, contains_key="present_count") | |
| if mark_data: | |
| ok(f" after manual mark: present={mark_data.get('present_count')} " | |
| f"late={mark_data.get('late_count')} absent={mark_data.get('absent_count')}") | |
| # ── 7b. Verify absent is stored as an explicit DB record ────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{created_session_id}/records") | |
| records = check("GET /api/v1/attendance/sessions/{id}/records (after manual mark)", r) | |
| if records is not None: | |
| recs = r.json() if r else [] | |
| absent_recs = [x for x in recs if x.get("attendee_id") == created_student_ids[2]] | |
| if absent_recs and absent_recs[0].get("status") == "absent": | |
| ok(" absent stored as explicit record", f"marked_by={absent_recs[0].get('marked_by')}") | |
| else: | |
| fail(" absent stored as explicit record", | |
| f"no absent record found for student[2] (got {absent_recs})") | |
| # Verify marked_by is set (should be the admin's email, not None) | |
| for rec in recs: | |
| if rec.get("attendee_id") in created_student_ids[:3] and not rec.get("marked_by"): | |
| fail(" marked_by is set on manual records", f"record {rec.get('id')} has marked_by=None") | |
| break | |
| else: | |
| ok(" marked_by set on all manual records") | |
| # ── 8. QR dev-simulate ──────────────────────────────────────────────── | |
| r = post(base, f"/api/v1/attendance/sessions/{created_session_id}/dev-simulate", | |
| {"student_ids": [created_student_ids[3]]}) | |
| if r is None: | |
| fail("POST /api/v1/attendance/sessions/{id}/dev-simulate", "connection error") | |
| elif r.status_code == 403: | |
| skip("POST /api/v1/attendance/sessions/{id}/dev-simulate", | |
| "403 — only works in IS_LOCAL=true mode") | |
| else: | |
| sim = check("POST /api/v1/attendance/sessions/{id}/dev-simulate (student 4)", r, | |
| contains_key="present_count") | |
| if sim: | |
| ok(f" dev-simulate present_count", str(sim.get("present_count", 0))) | |
| # ── 9. Final summary ────────────────────────────────────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{created_session_id}/summary") | |
| final = check("GET /api/v1/attendance/sessions/{id}/summary (final)", r, contains_key="present_count") | |
| if final: | |
| ok(f" final: enrolled={final.get('enrolled_count')} present={final.get('present_count')} " | |
| f"late={final.get('late_count')} absent={final.get('absent_count')}") | |
| finally: | |
| # ── 10. Cleanup ─────────────────────────────────────────────────────── | |
| if created_session_id: | |
| rd = delete(base, f"/api/v1/sessions/{created_session_id}") | |
| if rd and rd.ok: | |
| ok("Cleanup: session deleted") | |
| else: | |
| fail("Cleanup: delete session", f"HTTP {rd.status_code if rd else 'err'}") | |
| if created_batch_id: | |
| for sid in created_student_ids: | |
| delete(base, f"/api/v1/batches/{created_batch_id}/enroll/{sid}") | |
| rd = delete(base, f"/api/v1/batches/{created_batch_id}") | |
| if rd and rd.ok: | |
| ok("Cleanup: batch deleted") | |
| else: | |
| fail("Cleanup: delete batch", f"HTTP {rd.status_code if rd else 'err'}") | |
| for sid in created_student_ids: | |
| rd = delete(base, f"/api/v1/students/{sid}") | |
| if not (rd and rd.ok): | |
| fail(f"Cleanup: delete student {sid}", f"HTTP {rd.status_code if rd else 'err'}") | |
| if created_student_ids: | |
| ok(f"Cleanup: {len(created_student_ids)} students deleted") | |
| def _login_as(base: str, email: str) -> str | None: | |
| """Request + verify OTP (DEFAULT_OTP=123456) for a given email; return JWT or None.""" | |
| r1 = post(base, "/api/v1/auth/request-otp", {"email": email, "channel": "email"}, auth=False) | |
| if r1 is None or not r1.ok: | |
| fail(f"request-otp for {email}", f"HTTP {r1.status_code if r1 else 'err'}") | |
| return None | |
| r2 = post(base, "/api/v1/auth/verify-otp", {"email": email, "otp": "123456"}, auth=False) | |
| if r2 is None or not r2.ok: | |
| fail(f"verify-otp for {email}", f"HTTP {r2.status_code if r2 else 'err'}") | |
| return None | |
| return r2.json().get("access_token") | |
| def _authed_get(base: str, path: str, token: str) -> requests.Response | None: | |
| """GET with a specific JWT (not the global admin token).""" | |
| try: | |
| return requests.get(f"{base}{path}", headers={"Authorization": f"Bearer {token}"}, timeout=30) | |
| except Exception as exc: | |
| print(f" connection error: {exc}") | |
| return None | |
| def test_rich_attendance_scenarios(base: str): | |
| """ | |
| Self-contained end-to-end scenario test with rich attendance states. | |
| Creates everything it needs, runs assertions, then cleans up. | |
| Scenario: | |
| - 2 batches: one Active (DA course, mentor-da1), one Completed (DSA, mentor-dsa1) | |
| - 3 students: one per expected outcome (present / late / absent) | |
| - Sessions: 2 past + 1 future on active batch; 2 backdated on completed batch | |
| - Marks mixed attendance on all past sessions | |
| - Verifies: summary counts, marked_by audit trail, attendance matrix | |
| - Mentor login: checks dashboard shows the active att-batch | |
| - Student logins (x3): each checks own dashboard attendance status | |
| """ | |
| section("Rich Attendance Scenarios (multi-role, self-contained)") | |
| MENTOR_EMAIL = "mentor-da1@example.com" # exists in sync_payload / generate_test_data | |
| MENTOR_DSA = "mentor-dsa1@example.com" | |
| today = date.today() | |
| past1 = (today - timedelta(days=14)).isoformat() | |
| past2 = (today - timedelta(days=7)).isoformat() | |
| future1 = (today + timedelta(days=7)).isoformat() | |
| comp_d1 = "2026-01-12" | |
| comp_d2 = "2026-01-19" | |
| # Pick course IDs from live API so we are not hard-coding | |
| r = get(base, "/api/v1/courses") | |
| if r is None or not r.ok or not r.json(): | |
| skip("Rich Attendance Scenarios", "no courses found — run sync first") | |
| return | |
| courses = {c["title"]: c["id"] for c in r.json()} | |
| da_course = courses.get("Data Analytics") | |
| dsa_course = courses.get("Data Structures & Algorithms") | |
| if not da_course or not dsa_course: | |
| skip("Rich Attendance Scenarios", "DA / DSA courses not found — run sync first") | |
| return | |
| r = get(base, "/api/v1/mentors") | |
| if r is None or not r.ok or not r.json(): | |
| skip("Rich Attendance Scenarios", "no mentors found — run sync first") | |
| return | |
| mentors_by_email = {m["email"]: m["id"] for m in r.json()} | |
| da_mentor_id = mentors_by_email.get(MENTOR_EMAIL) | |
| dsa_mentor_id = mentors_by_email.get(MENTOR_DSA) | |
| if not da_mentor_id or not dsa_mentor_id: | |
| skip("Rich Attendance Scenarios", f"mentor {MENTOR_EMAIL} or {MENTOR_DSA} not found — run sync first") | |
| return | |
| created_students: list[dict] = [] # {id, email, expected} | |
| created_batch_active: str | None = None | |
| created_batch_completed: str | None = None | |
| created_sessions: list[str] = [] | |
| try: | |
| # ── 1. Create 3 test students ───────────────────────────────────────── | |
| for label, expected in (("present", "present"), ("late", "late"), ("absent", "absent")): | |
| r = post(base, "/api/v1/students", { | |
| "name": f"RichTest {label.title()} Student", | |
| "email": f"richtest.{label}@example.com", | |
| "mobile_number": f"6000000{'1' if label=='present' else '2' if label=='late' else '3'}", | |
| }) | |
| st = check(f"Create student ({label})", r, status=201, contains_key="id") | |
| if not st: | |
| return | |
| created_students.append({"id": st["id"], "email": st["email"], "expected": expected}) | |
| student_ids = [s["id"] for s in created_students] | |
| st_present, st_late, st_absent = created_students | |
| # ── 2. Create active batch (DA, mentor-da1) ─────────────────────────── | |
| r = post(base, "/api/v1/batches", { | |
| "name": "RichTest-Active", "course_id": da_course, | |
| "mentor_id": da_mentor_id, "start_date": past1, "status": "Active", | |
| }) | |
| b = check("Create RichTest-Active batch", r, status=201, contains_key="id") | |
| if not b: | |
| return | |
| created_batch_active = b["id"] | |
| # ── 3. Create completed batch (DSA, mentor-dsa1) ────────────────────── | |
| r = post(base, "/api/v1/batches", { | |
| "name": "RichTest-Completed", "course_id": dsa_course, | |
| "mentor_id": dsa_mentor_id, "start_date": comp_d1, "status": "Completed", | |
| }) | |
| b = check("Create RichTest-Completed batch", r, status=201, contains_key="id") | |
| if not b: | |
| return | |
| created_batch_completed = b["id"] | |
| # ── 4. Enroll all 3 students into both batches ──────────────────────── | |
| for bid in (created_batch_active, created_batch_completed): | |
| r = post(base, f"/api/v1/batches/{bid}/enroll", {"student_ids": student_ids}) | |
| check(f"Enroll 3 students into {bid}", r) | |
| # ── 5. Create sessions ──────────────────────────────────────────────── | |
| # Active batch: past-14d, past-7d, future | |
| for d_str, lbl in ((past1, "past-14d"), (past2, "past-7d"), (future1, "future-7d")): | |
| r = post(base, "/api/v1/sessions", { | |
| "batch_id": created_batch_active, "date": d_str, "slot_id": 2, "status": "Confirmed" | |
| }) | |
| s = check(f"Create session {lbl} (active batch)", r, status=201, contains_key="id") | |
| if s: | |
| created_sessions.append(s["id"]) | |
| # Completed batch: 2 backdated sessions | |
| for d_str, lbl in ((comp_d1, "comp-s1"), (comp_d2, "comp-s2")): | |
| r = post(base, "/api/v1/sessions", { | |
| "batch_id": created_batch_completed, "date": d_str, "slot_id": 1, "status": "Confirmed" | |
| }) | |
| s = check(f"Create session {lbl} (completed batch)", r, status=201, contains_key="id") | |
| if s: | |
| created_sessions.append(s["id"]) | |
| if len(created_sessions) < 5: | |
| fail("Session creation", f"only {len(created_sessions)} created, need 5") | |
| return | |
| sess_past1, sess_past2, _sess_future, sess_comp1, sess_comp2 = created_sessions[:5] | |
| # ── 6. Mark attendance ──────────────────────────────────────────────── | |
| # active/past-14d: one each of present, late, absent | |
| r = post(base, f"/api/v1/attendance/sessions/{sess_past1}/mark", {"entries": [ | |
| {"student_id": st_present["id"], "status": "present"}, | |
| {"student_id": st_late["id"], "status": "late"}, | |
| {"student_id": st_absent["id"], "status": "absent"}, | |
| ]}) | |
| m = check("Mark past-14d: present/late/absent", r, contains_key="present_count") | |
| if m: | |
| ok(f" counts", f"present={m['present_count']} late={m['late_count']} absent={m['absent_count']}") | |
| # active/past-7d: all present | |
| r = post(base, f"/api/v1/attendance/sessions/{sess_past2}/mark", {"entries": [ | |
| {"student_id": sid, "status": "present"} for sid in student_ids | |
| ]}) | |
| m = check("Mark past-7d: all present", r, contains_key="present_count") | |
| if m: | |
| ok(f" counts", f"present={m['present_count']} late={m['late_count']} absent={m['absent_count']}") | |
| # completed/comp-s1: 1 present, 2 absent | |
| r = post(base, f"/api/v1/attendance/sessions/{sess_comp1}/mark", {"entries": [ | |
| {"student_id": st_present["id"], "status": "present"}, | |
| {"student_id": st_late["id"], "status": "absent"}, | |
| {"student_id": st_absent["id"], "status": "absent"}, | |
| ]}) | |
| check("Mark comp-s1: 1 present / 2 absent", r, contains_key="present_count") | |
| # completed/comp-s2: late / present / absent | |
| r = post(base, f"/api/v1/attendance/sessions/{sess_comp2}/mark", {"entries": [ | |
| {"student_id": st_present["id"], "status": "late"}, | |
| {"student_id": st_late["id"], "status": "present"}, | |
| {"student_id": st_absent["id"], "status": "absent"}, | |
| ]}) | |
| check("Mark comp-s2: late / present / absent", r, contains_key="present_count") | |
| # ── 7. Verify session records have marked_by set ────────────────────── | |
| r = get(base, f"/api/v1/attendance/sessions/{sess_past1}/records") | |
| recs_data = check("Session records past-14d", r) | |
| if recs_data is not None: | |
| recs = r.json() | |
| missing = [x for x in recs if x.get("attendee_id") in student_ids and not x.get("marked_by")] | |
| if missing: | |
| fail(" marked_by set on all records", f"{len(missing)} records missing marked_by") | |
| else: | |
| ok(" all manual records have marked_by set") | |
| # ── 8. Attendance matrix for both batches ───────────────────────────── | |
| for bid, lbl in ((created_batch_active, "active"), (created_batch_completed, "completed")): | |
| r = get(base, f"/api/v1/attendance/batches/{bid}/matrix") | |
| data = check(f"Attendance matrix ({lbl} batch)", r, contains_key="matrix") | |
| if data: | |
| ok(f" sessions={len(data['sessions'])} enrolled={data['enrolled']}") | |
| # ── 9. Mentor login — dashboard shows the active att-batch ─────────── | |
| ok("--- Mentor login ---") | |
| mentor_token = _login_as(base, MENTOR_EMAIL) | |
| if not mentor_token: | |
| fail("Mentor login", f"could not get token for {MENTOR_EMAIL}") | |
| else: | |
| ok("Mentor login", MENTOR_EMAIL) | |
| r = _authed_get(base, "/api/v1/me/dashboard", mentor_token) | |
| dash = check("Mentor /me/dashboard", r, contains_key="batches") | |
| if dash: | |
| batch_ids = {b["batch_id"] for b in dash.get("batches", [])} | |
| if created_batch_active in batch_ids: | |
| ok(" RichTest-Active visible in mentor dashboard") | |
| else: | |
| fail(" RichTest-Active visible in mentor dashboard", f"found: {batch_ids}") | |
| # ── 10. Student logins — each sees correct attendance status ────────── | |
| ok("--- Student logins ---") | |
| for st in created_students: | |
| st_token = _login_as(base, st["email"]) | |
| if not st_token: | |
| fail(f"Student login {st['email']}", "could not get token") | |
| continue | |
| ok("Student login", st["email"]) | |
| r = _authed_get(base, "/api/v1/me/dashboard", st_token) | |
| dash = check(f" /me/dashboard ({st['expected']})", r, contains_key="batches") | |
| if not dash: | |
| continue | |
| att_batch = next((b for b in dash["batches"] if b["batch_id"] == created_batch_active), None) | |
| if not att_batch: | |
| fail(" active batch in student dashboard", "not found") | |
| continue | |
| summary = att_batch.get("attendance_summary", {}) | |
| ok(" attendance_summary", | |
| f"past={summary.get('past_sessions')} present={summary.get('present')} " | |
| f"absent={summary.get('absent')} pct={summary.get('attendance_pct')}%") | |
| # Verify per-session status for past-14d | |
| past14 = next((s for s in att_batch.get("sessions", []) if s.get("id") == sess_past1), None) | |
| if past14: | |
| actual = past14.get("attendance") | |
| expected = st["expected"] | |
| if actual == expected: | |
| ok(f" past-14d status for {st['expected']} student", f"{actual} == {expected}") | |
| else: | |
| fail(f" past-14d status for {st['expected']} student", f"expected={expected} got={actual}") | |
| else: | |
| fail(" past-14d session in student dashboard", f"session {sess_past1} not found") | |
| finally: | |
| # ── Cleanup ─────────────────────────────────────────────────────────── | |
| for sid in created_sessions: | |
| rd = delete(base, f"/api/v1/sessions/{sid}") | |
| if not (rd and rd.ok): | |
| fail(f"Cleanup session {sid}", f"HTTP {rd.status_code if rd else 'err'}") | |
| if created_sessions: | |
| ok(f"Cleanup: {len(created_sessions)} sessions deleted") | |
| for bid in filter(None, [created_batch_active, created_batch_completed]): | |
| for sid in student_ids if created_students else []: | |
| delete(base, f"/api/v1/batches/{bid}/enroll/{sid}") | |
| rd = delete(base, f"/api/v1/batches/{bid}") | |
| if rd and rd.ok: | |
| ok(f"Cleanup: batch {bid} deleted") | |
| else: | |
| fail(f"Cleanup batch {bid}", f"HTTP {rd.status_code if rd else 'err'}") | |
| for st in created_students: | |
| delete(base, f"/api/v1/students/{st['id']}") | |
| if created_students: | |
| ok(f"Cleanup: {len(created_students)} students deleted") | |
| def test_attendance_matrix(base: str): | |
| """ | |
| Tests GET /attendance/batches/{id}/matrix. | |
| Reuses the first active batch that has at least one session. | |
| Validates response structure: sessions list, students list, matrix keys/values. | |
| """ | |
| section("Attendance Matrix") | |
| # Find a batch with sessions | |
| r = get(base, "/api/v1/batches") | |
| if r is None or not r.ok: | |
| skip("Attendance matrix", "cannot reach /batches") | |
| return | |
| batches = r.json() | |
| if not batches: | |
| skip("Attendance matrix", "no batches in system") | |
| return | |
| # Prefer a batch with enrolled students; fall back to first batch | |
| target_batch = None | |
| for b in batches: | |
| if b.get("students") and len(b["students"]) > 0: | |
| target_batch = b | |
| break | |
| if not target_batch: | |
| target_batch = batches[0] | |
| batch_id = target_batch["id"] | |
| ok("Using batch", f"{target_batch.get('name', batch_id)} id={batch_id}") | |
| r = get(base, f"/api/v1/attendance/batches/{batch_id}/matrix") | |
| data = check(f"GET /api/v1/attendance/batches/{{id}}/matrix", r, contains_key="sessions") | |
| if not data: | |
| return | |
| # Validate top-level structure | |
| for key in ("batch_id", "batch_name", "enrolled", "sessions", "students", "matrix"): | |
| if key not in data: | |
| fail(f" response has key '{key}'", f"missing from response") | |
| return | |
| ok(" response structure valid", f"sessions={len(data['sessions'])} students={len(data['students'])}") | |
| # sessions list: each item must have id, date, slot_id, label | |
| for s in data["sessions"]: | |
| for field in ("id", "date", "slot_id", "label"): | |
| if field not in s: | |
| fail(f" session item has field '{field}'", f"missing in {s}") | |
| return | |
| if data["sessions"]: | |
| ok(" session items have required fields") | |
| # students list: each item must have id, name | |
| for st in data["students"]: | |
| for field in ("id", "name"): | |
| if field not in st: | |
| fail(f" student item has field '{field}'", f"missing in {st}") | |
| return | |
| if data["students"]: | |
| ok(" student items have required fields") | |
| # matrix: keys are student IDs; inner values are valid statuses or null | |
| VALID_STATUSES = {"present", "late", "absent", None} | |
| matrix = data.get("matrix", {}) | |
| student_ids = {st["id"] for st in data["students"]} | |
| if student_ids and matrix: | |
| for sid, sess_map in matrix.items(): | |
| if sid not in student_ids: | |
| fail(" matrix keys match enrolled students", f"unexpected student_id {sid} in matrix") | |
| return | |
| for sess_id, status in sess_map.items(): | |
| if status not in VALID_STATUSES: | |
| fail(" matrix status values are valid", f"invalid status '{status}' for student={sid} session={sess_id}") | |
| return | |
| ok(" matrix keys and status values valid") | |
| else: | |
| ok(" matrix empty (no sessions or no students) — structure OK") | |
| # enrolled count matches students list | |
| if data["enrolled"] != len(data["students"]): | |
| fail(" enrolled count matches students list", | |
| f"enrolled={data['enrolled']} but students={len(data['students'])}") | |
| else: | |
| ok(" enrolled count matches students list", str(data["enrolled"])) | |
| def test_cancellation(base: str): | |
| """ | |
| Self-contained cancellation workflow test. | |
| Creates a future session, cancels it, verifies all workflow steps, | |
| checks that QR/scan/mark are blocked, checks attendance % is unaffected, | |
| tests retry endpoint, then cleans up. | |
| """ | |
| section("Session Cancellation Workflow (self-contained)") | |
| today = date.today().isoformat() | |
| future_date = (date.today() + timedelta(days=14)).isoformat() | |
| # ── 0. Pre-requisites: need a batch (course + mentor) and a room ────────── | |
| r_courses = get(base, "/api/v1/courses") | |
| r_mentors = get(base, "/api/v1/mentors") | |
| r_rooms = get(base, "/api/v1/rooms") | |
| if not (r_courses and r_courses.ok and r_courses.json()): | |
| skip("Cancellation tests", "no courses available") | |
| return | |
| if not (r_mentors and r_mentors.ok and r_mentors.json()): | |
| skip("Cancellation tests", "no mentors available") | |
| return | |
| course = r_courses.json()[0] | |
| mentor = r_mentors.json()[0] | |
| created_student_ids: list[str] = [] | |
| created_batch_id: str | None = None | |
| created_session_id: str | None = None | |
| try: | |
| # ── 1. Create 2 test students ───────────────────────────────────────── | |
| for i in range(1, 3): | |
| r = post(base, "/api/v1/students", { | |
| "name": f"_cancel_student_{i}", | |
| "email": f"cancel.student{i}@example.com", | |
| "mobile_number": f"+91 77777 {i:05d}", | |
| }) | |
| st = check(f"POST /api/v1/students (cancel test student {i})", r, status=201, contains_key="id") | |
| if st: | |
| created_student_ids.append(st["id"]) | |
| if len(created_student_ids) < 2: | |
| fail("Create students for cancel test", "not enough students created") | |
| return | |
| # ── 2. Create batch ─────────────────────────────────────────────────── | |
| r = post(base, "/api/v1/batches", { | |
| "name": "_cancel-test-batch", | |
| "course_id": course["id"], | |
| "mentor_id": mentor["id"], | |
| "start_date": today, | |
| "status": "Active", | |
| }) | |
| batch = check("POST /api/v1/batches (cancel test)", r, status=201, contains_key="id") | |
| if not batch: | |
| return | |
| created_batch_id = batch["id"] | |
| # Enroll students | |
| r = post(base, f"/api/v1/batches/{created_batch_id}/enroll", | |
| {"student_ids": created_student_ids}) | |
| if not (r and r.ok): | |
| fail("Enroll students for cancel test", f"HTTP {r.status_code if r else 'err'}") | |
| return | |
| ok("Enrolled 2 students in cancel-test batch") | |
| # ── 3. Create a future session ──────────────────────────────────────── | |
| r = post(base, "/api/v1/sessions", { | |
| "batch_id": created_batch_id, | |
| "date": future_date, | |
| "slot_id": 1, | |
| "status": "Confirmed", | |
| }) | |
| sess = check(f"POST /api/v1/sessions (future session {future_date})", | |
| r, status=201, contains_key="id") | |
| if not sess: | |
| return | |
| created_session_id = sess["id"] | |
| if sess.get("status") == "Confirmed": | |
| ok(" session.status=Confirmed before cancel") | |
| # ── 4. Cancel the session ───────────────────────────────────────────── | |
| r = post(base, f"/api/v1/sessions/{created_session_id}/cancel", | |
| {"reason": "Test cancellation — automated test"}) | |
| wf = check("POST /api/v1/sessions/{id}/cancel", r, contains_key="step_cancel") | |
| if not wf: | |
| return | |
| if wf.get("step_cancel") == "done": | |
| ok(" workflow.step_cancel=done") | |
| else: | |
| fail(" workflow.step_cancel=done", f"got {wf.get('step_cancel')}") | |
| if wf.get("step_notify") in ("done", "skipped"): | |
| ok(f" workflow.step_notify={wf['step_notify']}") | |
| elif wf.get("step_notify") == "failed": | |
| ok(f" workflow.step_notify=failed (expected in CI — no SendGrid)") | |
| else: | |
| fail(" workflow.step_notify expected done/skipped/failed", f"got {wf.get('step_notify')}") | |
| if wf.get("step_reschedule") in ("done", "could_not_place", "failed"): | |
| ok(f" workflow.step_reschedule={wf['step_reschedule']}") | |
| else: | |
| fail(" workflow.step_reschedule expected done/could_not_place/failed", | |
| f"got {wf.get('step_reschedule')}") | |
| if wf.get("overall_status") in ("completed", "failed"): | |
| ok(f" workflow.overall_status={wf['overall_status']}") | |
| else: | |
| fail(" workflow.overall_status expected completed/failed", f"got {wf.get('overall_status')}") | |
| if wf.get("notes") and len(wf["notes"]) > 0: | |
| ok(f" workflow.notes has {len(wf['notes'])} entries") | |
| else: | |
| fail(" workflow.notes populated", f"got {wf.get('notes')}") | |
| # ── 5. Verify session status is now Cancelled ───────────────────────── | |
| r = get(base, f"/api/v1/sessions/{created_session_id}") | |
| cancelled_sess = check("GET /api/v1/sessions/{id} (after cancel)", r, contains_key="status") | |
| if cancelled_sess: | |
| if cancelled_sess.get("status") == "Cancelled": | |
| ok(" session.status=Cancelled confirmed") | |
| else: | |
| fail(" session.status=Cancelled", f"got {cancelled_sess.get('status')}") | |
| # ── 6. Cancel already-cancelled → 400 ──────────────────────────────── | |
| r2 = post(base, f"/api/v1/sessions/{created_session_id}/cancel", {"reason": "double cancel"}) | |
| if r2 is not None and r2.status_code == 400: | |
| ok("POST /api/v1/sessions/{id}/cancel (already cancelled) → 400") | |
| else: | |
| fail("Double-cancel → 400", f"HTTP {r2.status_code if r2 else 'unreachable'}") | |
| # ── 7. GET workflow ─────────────────────────────────────────────────── | |
| r3 = get(base, f"/api/v1/sessions/{created_session_id}/cancel") | |
| wf2 = check("GET /api/v1/sessions/{id}/cancel (workflow read)", r3, contains_key="session_id") | |
| if wf2: | |
| if wf2.get("session_id") == created_session_id: | |
| ok(" workflow.session_id matches") | |
| if wf2.get("batch_name") == "_cancel-test-batch": | |
| ok(" workflow snapshot batch_name correct") | |
| # ── 8. Retry → 400 if already completed ────────────────────────────── | |
| if wf.get("overall_status") == "completed": | |
| r4 = post(base, f"/api/v1/sessions/{created_session_id}/cancel/retry", {}) | |
| if r4 is not None and r4.status_code == 400: | |
| ok("POST retry on completed workflow → 400") | |
| else: | |
| ok(f"POST retry on completed workflow → {r4.status_code if r4 else 'err'} (expected 400)") | |
| # ── 9. Attendance blocked for cancelled session ─────────────────────── | |
| r_qr = get(base, f"/api/v1/attendance/sessions/{created_session_id}/qr") | |
| if r_qr is not None and r_qr.status_code == 409: | |
| ok("GET /attendance/{id}/qr on cancelled session → 409") | |
| else: | |
| fail("GET /attendance/{id}/qr on cancelled → 409", | |
| f"HTTP {r_qr.status_code if r_qr else 'unreachable'}") | |
| r_mark = post(base, f"/api/v1/attendance/sessions/{created_session_id}/mark", | |
| {"entries": [{"student_id": created_student_ids[0], "status": "present"}]}) | |
| if r_mark is not None and r_mark.status_code == 409: | |
| ok("POST /attendance/{id}/mark on cancelled session → 409") | |
| else: | |
| fail("POST /attendance/{id}/mark on cancelled → 409", | |
| f"HTTP {r_mark.status_code if r_mark else 'unreachable'}") | |
| # ── 10. Student dashboard excludes cancelled from % denominator ─────── | |
| r_dash = get(base, "/api/v1/me/dashboard") | |
| if r_dash and r_dash.ok: | |
| ok("GET /me/dashboard (post-cancel) → 200") | |
| else: | |
| fail("GET /me/dashboard (post-cancel)", f"HTTP {r_dash.status_code if r_dash else 'err'}") | |
| # ── 11. Verify sessions list shows Cancelled status ─────────────────── | |
| r_list = get(base, f"/api/v1/sessions?batch_id={created_batch_id}") | |
| if r_list and r_list.ok: | |
| statuses = [s.get("status") for s in r_list.json()] | |
| if "Cancelled" in statuses: | |
| ok(" Cancelled session appears in sessions list with correct status") | |
| else: | |
| fail(" Cancelled session in sessions list", f"statuses found: {statuses}") | |
| finally: | |
| # ── Cleanup ─────────────────────────────────────────────────────────── | |
| if created_session_id: | |
| # Session is cancelled (not soft-deleted), so we soft-delete it | |
| rd = delete(base, f"/api/v1/sessions/{created_session_id}") | |
| if rd and rd.ok: | |
| ok("Cleanup: cancelled session soft-deleted") | |
| else: | |
| # 409 means it's locked — not expected but handle gracefully | |
| ok(f"Cleanup: session delete returned {rd.status_code if rd else 'err'}") | |
| if created_batch_id: | |
| for sid in created_student_ids: | |
| delete(base, f"/api/v1/batches/{created_batch_id}/enroll/{sid}") | |
| rd = delete(base, f"/api/v1/batches/{created_batch_id}") | |
| if rd and rd.ok: | |
| ok("Cleanup: cancel-test batch deleted") | |
| for sid in created_student_ids: | |
| delete(base, f"/api/v1/students/{sid}") | |
| if created_student_ids: | |
| ok(f"Cleanup: {len(created_student_ids)} cancel-test students deleted") | |
| # ── Runner ───────────────────────────────────────────────────────────────────── | |
| def _resolve_db_path(dotenv: dict) -> str | None: | |
| """ | |
| Return the local SQLite file path from DATABASE_URL (env or .env file). | |
| Returns None if the URL points to Postgres or is not set. | |
| """ | |
| url = ( | |
| os.environ.get("DATABASE_URL") | |
| or dotenv.get("DATABASE_URL") | |
| or "sqlite+aiosqlite:////data/smart_scheduling.db" | |
| ) | |
| if not url.startswith("sqlite"): | |
| return None | |
| # sqlite+aiosqlite:///[/]path → strip the scheme prefix (3 slashes) | |
| path = url.split("sqlite+aiosqlite:///", 1)[-1] | |
| # On Windows an extra leading slash may be present; os.path.exists handles it | |
| return path | |
| def clean_db(dotenv: dict): | |
| """ | |
| Wipe all application data by deleting the SQLite database file. | |
| No server required. The server will recreate the schema on next startup. | |
| """ | |
| db_path = _resolve_db_path(dotenv) | |
| print(f"\n{BOLD}=== DB CLEAN ==={RESET}") | |
| if db_path is None: | |
| print(f" {RED}ERROR{RESET} DATABASE_URL points to Postgres — cannot delete file.") | |
| print(f" Drop and recreate the Postgres database manually.") | |
| print(f"{BOLD}=== DONE ==={RESET}\n") | |
| return | |
| if not os.path.exists(db_path): | |
| print(f" {YELLOW}SKIP{RESET} DB file not found: {db_path}") | |
| print(f" Nothing to clean.") | |
| print(f"{BOLD}=== DONE ==={RESET}\n") | |
| return | |
| os.remove(db_path) | |
| print(f" {GREEN}OK{RESET} Deleted: {db_path}") | |
| print(f" Restart the server — it will create a fresh empty database.") | |
| print(f"{BOLD}=== DONE ==={RESET}\n") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "SmartSched API test suite and DB utility.\n\n" | |
| "Environment config (base URL, HF token) is read from test env files:\n" | |
| " test_data/.env.test — local (default)\n" | |
| " test_data/.env.test.hf — HF Spaces (copy from .env.test.hf.example)\n\n" | |
| "You must pass at least one action flag (--run or --clean-db).\n" | |
| "Running with no flags prints this help message." | |
| ), | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| # ── Environment selector ────────────────────────────────────────────────── | |
| parser.add_argument("--env", choices=["local", "hf"], default="local", | |
| help=( | |
| "Which test env file to load: 'local' (default) reads " | |
| "test_data/.env.test; 'hf' reads test_data/.env.test.hf" | |
| )) | |
| # ── Action flags (at least one required) ────────────────────────────────── | |
| actions = parser.add_argument_group("actions (choose at least one)") | |
| actions.add_argument("--run", action="store_true", | |
| help="Run the full API test suite against the target server") | |
| actions.add_argument("--clean-db", action="store_true", | |
| help=( | |
| "Delete the local SQLite database file and exit. " | |
| "No server required — the server will recreate the schema on next startup. " | |
| "For Postgres targets, prints instructions instead." | |
| )) | |
| # ── Test modifiers (only relevant with --run) ───────────────────────────── | |
| mods = parser.add_argument_group("test modifiers (used with --run)") | |
| mods.add_argument("--smoke", action="store_true", | |
| help="Skip schedule generation — fast smoke test of CRUD and auth only") | |
| mods.add_argument("--skip-sync", action="store_true", | |
| help="Skip CSV bulk-import and enroll steps (use when seed data is already loaded)") | |
| args = parser.parse_args() | |
| # Show help and exit if no action is requested | |
| if not args.run and not args.clean_db: | |
| parser.print_help() | |
| sys.exit(0) | |
| # --clean-db: no server needed — delete SQLite file directly | |
| if args.clean_db: | |
| clean_db(_dotenv_early) | |
| sys.exit(0) | |
| # ── Everything below requires the server to be running ─────────────────── | |
| base = os.environ.get("TEST_BASE_URL", "http://localhost:7880").rstrip("/") | |
| global _admin_email, _is_local, _hf_token | |
| _admin_email = ( | |
| os.environ.get("TEST_ADMIN_EMAIL") | |
| or os.environ.get("ADMIN_EMAILS", "").split(",")[0].strip() | |
| or "admin@geeksforgeeks.org" | |
| ) | |
| # Local mode = localhost/LAN hostname OR IS_LOCAL=true in .env | |
| import urllib.parse as _urlparse | |
| _hostname = _urlparse.urlparse(base).hostname or "" | |
| _is_local = ( | |
| _hostname in ("localhost", "127.0.0.1", "0.0.0.0") | |
| or os.environ.get("IS_LOCAL", "false").lower() == "true" | |
| ) | |
| _hf_token = os.environ.get("HF_TOKEN") | |
| os.makedirs(LOGS, exist_ok=True) | |
| target = base.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "-") | |
| ts = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") | |
| log_path = os.path.join(LOGS, f"test_{ts}_{target}.log") | |
| tee = _Tee(log_path) | |
| sys.stdout = tee | |
| print(f"\n{BOLD}SmartSched API Test Suite{RESET}") | |
| print(f"Target: {BOLD}{base}{RESET}") | |
| print(f"Env: {args.env} ({_test_env_file})") | |
| print(f"Mode: {'smoke' if args.smoke else 'full'}") | |
| print(f"Log: {log_path}") | |
| print(f"Admin: {_admin_email}") | |
| if _hf_token: | |
| print(f"HF: token loaded ({_hf_token[:8]}...)") | |
| else: | |
| print(f"HF: no token (local mode or public space)") | |
| t0 = time.time() | |
| # Auth must run first — sets the global token used by all subsequent calls | |
| auth_ok = test_auth(base) | |
| if not auth_ok: | |
| print(f"\n{RED}Auth failed — cannot continue. " | |
| f"Ensure the server is running locally and {_admin_email} is in ADMIN_EMAILS.{RESET}\n") | |
| sys.exit(1) | |
| test_health(base) | |
| test_me_dashboard(base) | |
| if not args.skip_sync: | |
| synced = test_sync(base) | |
| if synced: | |
| test_enroll(base) | |
| else: | |
| skip("Sync + Enroll", "--skip-sync flag set") | |
| courses = test_courses(base) | |
| rooms = test_rooms(base) | |
| mentors = test_mentors(base) | |
| students = test_students(base) | |
| batches = test_batches(base) | |
| if batches: | |
| test_schedule(base, smoke=args.smoke) | |
| else: | |
| skip("Schedule generation", "no batches found") | |
| test_calendar(base, students, mentors) | |
| test_conflicts(base) | |
| sessions_data = [] | |
| r_sess = get(base, "/api/v1/sessions") | |
| if r_sess and r_sess.ok: | |
| sessions_data = r_sess.json() or [] | |
| test_sessions(base) | |
| test_attendance(base, students, sessions_data) | |
| test_adhoc_attendance(base) | |
| test_rich_attendance_scenarios(base) | |
| test_attendance_matrix(base) | |
| test_cancellation(base) | |
| test_settings(base) | |
| test_slot_validation(base) | |
| test_soft_delete_audit(base) | |
| test_crud_create_delete(base) | |
| elapsed = time.time() - t0 | |
| print(f"\n{BOLD}{'-'*66}{RESET}") | |
| total = passed + failed + skipped | |
| color = GREEN if failed == 0 else RED | |
| print(f"{BOLD} Results: {color}{passed} passed{RESET} | " | |
| f"{RED}{failed} failed{RESET} | " | |
| f"{YELLOW}{skipped} skipped{RESET} | " | |
| f"{total} total ({elapsed:.1f}s){RESET}") | |
| print(f"{BOLD}{'-'*66}{RESET}\n") | |
| tee.close() | |
| sys.stdout = sys.__stdout__ | |
| print(f"Log written -> {log_path}") | |
| sys.exit(0 if failed == 0 else 1) | |
| if __name__ == "__main__": | |
| main() | |