436 lines
17 KiB
Python
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}")
|