""" 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() @property 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()