Files
plugins/openedx-tenant-api/test_e2e/conftest.py
DamarKusumo 2b7027e37d Add openedx-tenant-api plugin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:20:57 +07:00

436 lines
17 KiB
Python

"""Pytest configuration and fixtures for E2E tests."""
import ast
import logging
import pytest
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env file from test_e2e directory
load_dotenv(Path(__file__).parent / ".env")
logger = logging.getLogger(__name__)
@pytest.fixture(scope="session")
def base_url():
"""Base URL for the LMS."""
return os.getenv("LMS_BASE_URL", "http://localhost:8000")
@pytest.fixture(scope="session")
def studio_base_url():
"""Base URL for Studio."""
return os.getenv("STUDIO_BASE_URL", "http://localhost:8001")
@pytest.fixture(scope="session")
def authn_base_url():
"""Base URL for Authn MFE."""
return os.getenv("AUTHN_BASE_URL", "http://localhost:1999")
@pytest.fixture(scope="session")
def learner_dashboard_base_url():
"""Base URL for Learner Dashboard MFE."""
return os.getenv("LEARNER_DASHBOARD_BASE_URL", "http://localhost:1996")
@pytest.fixture(scope="session")
def studio_mfe_base_url():
"""Base URL for Studio MFE."""
return os.getenv("STUDIO_MFE_BASE_URL", "http://localhost:2001")
@pytest.fixture(scope="session")
def tenant_domain(tenant_config):
"""Tenant domain for LMS (e.g., mondaytest.local.openedx.io)."""
return f"{tenant_config['tenant_name']}.local.openedx.io"
@pytest.fixture(scope="session")
def tenant_apps_domain(tenant_config):
"""Tenant apps domain for MFEs (e.g., mondaytest.apps.local.openedx.io)."""
return f"{tenant_config['tenant_name']}.apps.local.openedx.io"
@pytest.fixture(scope="session")
def admin_auth():
"""Admin credentials for API authentication (superuser)."""
return {
"username": os.getenv("API_ADMIN_USERNAME", "admin"),
"password": os.getenv("API_ADMIN_PASSWORD", "admin123"),
}
@pytest.fixture(scope="session")
def tenant_config():
"""Tenant configuration for the test."""
return {
"tenant_name": os.getenv("TENANT_NAME", "mondaytest"),
"platform_name": os.getenv("PLATFORM_NAME", "Monday Test Learning"),
"theme_name": os.getenv("THEME_NAME", "indigo"),
"org_filter": [os.getenv("ORG_FILTER", "monday")],
}
@pytest.fixture(scope="session")
def admin_config():
"""Admin user configuration."""
tenant_name = os.getenv("TENANT_NAME", "mondaytest")
return {
"tenant_name": tenant_name,
"username": os.getenv("ADMIN_USERNAME", f"{tenant_name}_admin"),
"email": os.getenv("ADMIN_EMAIL", f"{tenant_name}_admin@example.com"),
"password": os.getenv("ADMIN_PASSWORD", "admin123"),
"org_name": os.getenv("ORG_FILTER", "monday"),
}
@pytest.fixture(scope="session")
def course_config():
"""Course configuration."""
return {
"org": os.getenv("COURSE_ORG", "course"),
"number": os.getenv("COURSE_NUMBER", "101"),
"run": os.getenv("COURSE_RUN", "2026"),
"name": os.getenv("COURSE_NAME", "E2E Test Course"),
}
@pytest.fixture(scope="function")
def report_dir():
"""Directory for test reports."""
report_path = Path(__file__).parent / "reports"
report_path.mkdir(exist_ok=True)
return report_path
@pytest.fixture(scope="function")
def screenshot_dir():
"""Directory for screenshots."""
screenshot_path = Path(__file__).parent / "screenshots"
screenshot_path.mkdir(exist_ok=True)
return screenshot_path
@pytest.fixture(scope="function")
def browser_context_args(browser_context_args):
"""Browser context arguments for Playwright."""
return {
**browser_context_args,
"viewport": {"width": 1920, "height": 1080},
"record_video_dir": str(Path(__file__).parent / "videos"),
}
@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args):
"""Launch args to ignore certificate errors in dev."""
return {
**browser_type_launch_args,
"args": [
"--disable-web-security",
"--ignore-certificate-errors",
"--allow-insecure-localhost",
"--disable-site-isolation-trials",
],
}
@pytest.fixture(scope="function")
def context(context, tenant_config):
"""Create browser context with proper headers for tenant detection."""
tenant_name = tenant_config.get("tenant_name", "mondaytest")
tenant_host = f"{tenant_name}.local.openedx.io"
# Add extra HTTP headers for tenant detection
context.set_default_timeout(60000)
return context
# ---------------------------------------------------------------------------
# Auto-cleanup fixtures — run once before the test session starts
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session", autouse=True)
def auto_cleanup_tenants():
"""
Automatically delete ALL tenants before the test session starts.
This ensures a clean slate — no stale tenant configs interfere with tests.
Run with: pytest --co -q # to see what would run without executing
Skip with: pytest --no-cleanup
"""
if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"):
logger.info("Skipping auto-cleanup (SKIP_AUTO_CLEANUP=1)")
return
import subprocess
import requests
from dotenv import load_dotenv
# Re-load .env in case this runs in a different import context
load_dotenv(Path(__file__).parent / ".env")
lms_base_url = os.getenv("LMS_BASE_URL", "http://localhost:8000").rstrip("/")
admin_user = os.getenv("API_ADMIN_USERNAME", "admin")
admin_pass = os.getenv("API_ADMIN_PASSWORD", "admin123")
auth = (admin_user, admin_pass)
# List and delete all tenants
try:
list_url = f"{lms_base_url}/api/tenant/v1/list"
resp = requests.get(list_url, auth=auth, timeout=30)
resp.raise_for_status()
tenants = resp.json().get("tenants", [])
logger.info(f"[Auto-cleanup] Found {len(tenants)} tenant(s): {[t['tenant_name'] for t in tenants]}")
for tenant in tenants:
name = tenant["tenant_name"]
delete_url = f"{lms_base_url}/api/tenant/v1/delete/{name}"
try:
r = requests.delete(delete_url, auth=auth, timeout=30)
r.raise_for_status()
logger.info(f"[Auto-cleanup] Deleted tenant: {name}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Could not delete tenant '{name}': {e}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Could not list/delete tenants: {e}")
@pytest.fixture(scope="session", autouse=True)
def auto_cleanup_admins():
"""
Automatically delete all admin users (username ending with _admin)
created by the tenant API, after tenants are deleted.
"""
if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"):
logger.info("Skipping auto-cleanup admins (SKIP_AUTO_CLEANUP=1)")
return
import subprocess
# Resolve tutor executable as a list: e.g. ['C:\\...\\tutor.exe', 'dev']
env_tutor = os.getenv("TUTOR_CMD", "").strip()
if env_tutor:
tutor_cmd = env_tutor.split()
else:
project_root = Path(__file__).resolve().parent.parent.parent
venv_tutor = project_root / ".venv" / "Scripts" / "tutor.exe"
if venv_tutor.exists():
tutor_cmd = [str(venv_tutor), "dev"]
else:
tutor_cmd = ["tutor", "dev"]
def run_tutor(args, timeout=120):
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
# List admin users via LMS Django shell
try:
list_cmd = tutor_cmd + [
"exec", "-T", "lms",
"python", "manage.py", "lms", "shell", "-c",
"from django.contrib.auth import get_user_model; "
"User = get_user_model(); "
"admins = list(User.objects.filter(username__endswith='_admin').values_list('username', flat=True)); "
"print(repr(admins))",
]
result = run_tutor(list_cmd, timeout=60)
output = result.stdout + result.stderr
if result.returncode != 0:
stderr_lower = result.stderr.lower()
if "is not running" in stderr_lower or ("service" in stderr_lower and "not" in stderr_lower):
logger.warning(
"[Auto-cleanup] LMS is not running. Skipping admin cleanup.\n"
f"Start with: {' '.join(tutor_cmd)} start"
)
else:
logger.warning(f"[Auto-cleanup] Could not list admins: {result.stderr}")
return
admins = []
for line in output.strip().split("\n"):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
admins = ast.literal_eval(stripped)
break
logger.info(f"[Auto-cleanup] Found {len(admins)} admin user(s): {admins}")
for username in admins:
escaped_user = username.replace("'", "''")
delete_cmd = tutor_cmd + [
"exec", "-T", "lms",
"python", "manage.py", "lms", "shell", "-c",
(
"from django.db import connection; "
"cursor = connection.cursor(); "
"cursor.execute('SET FOREIGN_KEY_CHECKS = 0'); "
"cursor.execute('DELETE FROM auth_user WHERE username = %s', ['" + escaped_user + "']); "
"cursor.execute('SET FOREIGN_KEY_CHECKS = 1'); "
"print('DELETED_" + escaped_user + "')"
),
]
try:
dr = run_tutor(delete_cmd, timeout=60)
out = dr.stdout + dr.stderr
marker = f"DELETED_{username}"
if dr.returncode == 0 and marker in out:
logger.info(f"[Auto-cleanup] Deleted admin: {username}")
elif "FOREIGN_KEY_CHECKS" in dr.stderr and "Error" in dr.stderr:
logger.warning(f"[Auto-cleanup] Failed to delete '{username}': {dr.stderr.strip()[:200]}")
else:
logger.info(f"[Auto-cleanup] Admin '{username}' not found (already deleted)")
except subprocess.TimeoutExpired:
logger.warning(f"[Auto-cleanup] Timeout deleting admin: {username}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Error deleting '{username}': {e}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Admin cleanup failed: {e}")
@pytest.fixture(scope="session", autouse=True)
def auto_cleanup_courses():
"""
Automatically delete ALL courses before the test session starts.
Uses 'tutor dev exec -T cms python manage.py cms shell' with modulestore API
for each course found in CourseOverview.
Run with: pytest --co -q # to see what would run without executing
Skip with: pytest --no-cleanup
"""
if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"):
logger.info("Skipping auto-cleanup courses (SKIP_AUTO_CLEANUP=1)")
return
import subprocess
# Resolve tutor executable as a list: e.g. ['C:\\...\\tutor.exe', 'dev']
env_tutor = os.getenv("TUTOR_CMD", "").strip()
if env_tutor:
tutor_cmd = env_tutor.split()
else:
project_root = Path(__file__).resolve().parent.parent.parent
venv_tutor = project_root / ".venv" / "Scripts" / "tutor.exe"
if venv_tutor.exists():
tutor_cmd = [str(venv_tutor), "dev"]
else:
tutor_cmd = ["tutor", "dev"] # fallback to PATH
def run_tutor(args, timeout=120):
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
# List all course keys
try:
list_cmd = tutor_cmd + [
"exec", "lms",
"python", "manage.py", "lms", "shell", "-c",
"from openedx.core.djangoapps.content.course_overviews.models import CourseOverview; "
"print('\\n'.join([str(c.id) for c in CourseOverview.objects.all()]))",
]
result = run_tutor(list_cmd, timeout=60)
# Course data may be in stdout or stderr (Django logs to stderr)
output = result.stdout + result.stderr
if result.returncode != 0:
stderr_lower = result.stderr.lower()
if "is not running" in stderr_lower or ("service" in stderr_lower and "not" in stderr_lower):
logger.warning(
"[Auto-cleanup] LMS is not running. Skipping course cleanup.\n"
f"Start with: {' '.join(tutor_cmd)} start"
)
else:
logger.warning(f"[Auto-cleanup] Could not list courses (rc={result.returncode}): {result.stderr}")
return
# Filter out noise lines — only keep course-v1: lines
courses = [
line.strip()
for line in output.strip().split("\n")
if line.strip().startswith("course-v1:")
]
logger.info(f"[Auto-cleanup] Found {len(courses)} course(s): {courses}")
for course_key in courses:
# Escape single quotes in course_key for shell
escaped_key = course_key.replace("'", "'\\''")
delete_cmd = tutor_cmd + [
"exec", "-T", "cms",
"python", "manage.py", "cms", "shell", "-c",
(
f"from xmodule.modulestore.django import modulestore; "
f"from opaque_keys.edx.keys import CourseKey; "
f"from openedx.core.djangoapps.content.course_overviews.models import CourseOverview; "
f"store = modulestore(); "
f"key = CourseKey.from_string('{escaped_key}'); "
f"store.delete_course(key, None); "
# Also explicitly delete CourseOverview to avoid orphaned cache rows
# that cause courses to still appear in Studio UI after modulestore deletion
f"CourseOverview.objects.filter(id=key).delete(); "
f"print(f'DELETED: ' + str(key))"
),
]
try:
dr = run_tutor(delete_cmd, timeout=120)
output = dr.stdout + dr.stderr
if dr.returncode == 0 and f"DELETED: {course_key}" in output:
logger.info(f"[Auto-cleanup] Deleted course: {course_key}")
else:
err_lower = dr.stderr.lower()
if "not found" in err_lower or "does not exist" in err_lower or "does not have" in err_lower:
logger.info(f"[Auto-cleanup] Course already gone: {course_key}")
else:
logger.warning(f"[Auto-cleanup] Failed to delete '{course_key}': {dr.stderr.strip()[:200]}")
except subprocess.TimeoutExpired:
logger.warning(f"[Auto-cleanup] Timeout deleting course: {course_key}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Error deleting '{course_key}': {e}")
# Also clear Meilisearch course discovery indexes so courses don't appear
# on the /courses page even after database deletion
try:
import urllib.request
import json
# Get master key from Django settings
key_cmd = tutor_cmd + [
"exec", "lms", "python", "-c",
"from django.conf import settings; k=getattr(settings,'MEILISEARCH_MASTER_KEY',None); print(k or '')",
]
kr = run_tutor(key_cmd, timeout=30)
lines = [l.strip() for l in kr.stdout.splitlines() if l.strip()]
master_key = lines[-1] if lines else None
if not master_key:
logger.warning("[Auto-cleanup] Could not get Meilisearch master key")
return
host = "http://127.0.0.1:7700" # default accessible from host
headers = {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"}
# List all indexes
req = urllib.request.Request(f"{host}/indexes", headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
indexes = data.get("results", [])
course_indexes = [idx["uid"] for idx in indexes if "course" in idx["uid"].lower()]
for idx_uid in course_indexes:
try:
# Get doc count
req2 = urllib.request.Request(f"{host}/indexes/{idx_uid}/stats", headers=headers)
with urllib.request.urlopen(req2, timeout=15) as resp2:
stats = json.loads(resp2.read())
doc_count = stats.get("numberOfDocuments", 0)
# Delete all docs
req3 = urllib.request.Request(
f"{host}/indexes/{idx_uid}/documents", headers=headers, method="DELETE"
)
with urllib.request.urlopen(req3, timeout=15) as resp3:
task = json.loads(resp3.read())
logger.info(f"[Auto-cleanup] Meilisearch cleaned: {idx_uid} ({doc_count} docs, taskUid={task.get('taskUid')})")
except Exception as me:
logger.warning(f"[Auto-cleanup] Meilisearch cleanup failed for '{idx_uid}': {me}")
except Exception as me_err:
logger.warning(f"[Auto-cleanup] Meilisearch cleanup failed: {me_err}")
except Exception as e:
logger.warning(f"[Auto-cleanup] Course cleanup failed: {e}")