arcshukla's picture
cancellation schedule
f0d6ad3
Raw
History Blame Contribute Delete
87.9 kB
"""
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()