Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions tests/unit/fixtures/html/dashboard_legacy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!--
Fixture provenance: Moodle 3.11 dashboard (/my/) <head> with the legacy
generator <meta> release string. Build date is illustrative. Used to guard
the 3.x-vs-4.x strategy split.
Exercises: extract_version_from_dashboard (legacy generator-meta shape) and
get_strategy_for_version (< 4.x -> legacy strategy).
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="generator" content="Moodle 3.11.16+ (Build: 20230710)">
<title>Dashboard</title>
</head>
<body class="path-my">
<div id="page">Dashboard</div>
</body>
</html>
19 changes: 19 additions & 0 deletions tests/unit/fixtures/html/dashboard_modern.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!--
Fixture provenance: Moodle 5.x dashboard (/my/) <head>, trimmed to the
generator <meta> tag Moodle emits with its release string. The build date is
illustrative. No login token or sesskey present (used for the absent-marker
login-token case too).
Exercises: extract_version_from_dashboard (modern generator-meta shape) and
get_strategy_for_version (>= 4.x -> modern strategy).
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="generator" content="Moodle 5.1 (Build: 20250414)">
<title>Dashboard</title>
</head>
<body class="path-my">
<div id="page">Dashboard</div>
</body>
</html>
16 changes: 16 additions & 0 deletions tests/unit/fixtures/html/dashboard_no_version.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!--
Fixture provenance: A Moodle page with no generator <meta> and no release
string in embedded JavaScript, used to confirm version extraction degrades
gracefully to an "unknown" MoodleVersion instead of raising.
Exercises: extract_version_from_dashboard (absent-marker path).
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
</head>
<body class="path-my">
<div id="page">Dashboard content without any version metadata.</div>
</body>
</html>
30 changes: 30 additions & 0 deletions tests/unit/fixtures/html/folder_view_modern.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!--
Fixture provenance: Moodle 4.x/5.x folder activity view
(mod/folder/view.php), trimmed to the file listing. Each link targets the
/pluginfile.php/ file-serving endpoint; the numeric context id (99) is a FAKE
placeholder. The class-scoped tree lets both the modern (.folder_tree first)
and legacy (.foldertree first) selector orders resolve the same links.
Exercises: BaseCompatibilityStrategy.extract_folder_filenames.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Folder</title>
</head>
<body class="path-mod-folder">
<div class="folder_tree filemanager">
<ul>
<li>
<a href="https://moodle.example.test/pluginfile.php/99/mod_folder/content/0/syllabus.docx?forcedownload=1">syllabus.docx</a>
</li>
<li>
<a href="https://moodle.example.test/pluginfile.php/99/mod_folder/content/0/slides.pdf?forcedownload=1">slides.pdf</a>
</li>
<li>
<a href="https://moodle.example.test/pluginfile.php/99/mod_folder/content/0/notes.txt?forcedownload=1">notes.txt</a>
</li>
</ul>
</div>
</body>
</html>
24 changes: 24 additions & 0 deletions tests/unit/fixtures/html/login_page_modern.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!--
Fixture provenance: Moodle 4.x/5.x login page (/login/index.php), trimmed to
the login <form>. Hand-authored to mirror the hidden CSRF field Moodle emits.
The logintoken below is an OBVIOUSLY FAKE placeholder, never a real token.
Exercises: BaseCompatibilityStrategy.extract_login_token
(selector input[name="logintoken"]).
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Log in</title>
</head>
<body class="path-login">
<form action="https://moodle.example.test/login/index.php" method="post" id="login">
<input type="hidden" name="logintoken" value="fake-logintoken-9f8a7b6c5d4e3f2a">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="">
<label for="password">Password</label>
<input type="password" id="password" name="password" value="">
<button type="submit" id="loginbtn">Log in</button>
</form>
</body>
</html>
22 changes: 22 additions & 0 deletions tests/unit/fixtures/html/sesskey_config_modern.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!--
Fixture provenance: Moodle 4.x/5.x footer <script> emitting the M.cfg
JavaScript config block that carries the session key. Trimmed to the config
assignment. The sesskey below is an OBVIOUSLY FAKE placeholder.
Exercises: BaseCompatibilityStrategy.extract_sesskey (regex scan of the raw
HTML for the sesskey key inside the M.cfg config object).
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
</head>
<body class="path-my">
<div id="page">Dashboard content</div>
<script>
//<![CDATA[
M.cfg = {"wwwroot":"https://moodle.example.test","sesskey":"fakeSesskey1a2b3c","contextid":2,"loglevel":9};
//]]>
</script>
</body>
</html>
146 changes: 146 additions & 0 deletions tests/unit/test_html_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Offline regression tests for the brittle HTML parsers in ``py_moodle.compat``.

These are characterization tests: the parsers already exist, so each test pins
the current extraction behavior against a hand-authored HTML fixture file that
mirrors representative Moodle markup. They run fully network-free under
``make test-unit`` so a future Moodle-markup change (or an accidental selector
regression) is caught early.

The fixtures live in ``tests/unit/fixtures/html/`` and are loaded from disk via
``pathlib`` relative to this file. Every embedded token/sesskey/version string
is an obviously-fake placeholder, never a real secret.
"""

from pathlib import Path

import pytest
from bs4 import BeautifulSoup

from py_moodle.compat import (
DEFAULT_COMPATIBILITY,
LEGACY_COMPATIBILITY,
LegacyCompatibilityStrategy,
ModernCompatibilityStrategy,
extract_version_from_dashboard,
get_strategy_for_version,
)

FIXTURES_DIR = Path(__file__).parent / "fixtures" / "html"


def _load(name: str) -> str:
"""Read the text of an HTML fixture from the fixtures directory.

Args:
name: Fixture file name relative to ``fixtures/html/``.

Returns:
str: Decoded UTF-8 contents of the fixture.
"""
return (FIXTURES_DIR / name).read_text(encoding="utf-8")


def _soup(name: str) -> BeautifulSoup:
"""Parse an HTML fixture into a ``BeautifulSoup`` tree.

Args:
name: Fixture file name relative to ``fixtures/html/``.

Returns:
BeautifulSoup: Parsed document tree, using the same ``lxml`` parser the
production compatibility code relies on.
"""
return BeautifulSoup(_load(name), "lxml")


def test_extract_login_token_from_modern_login_page():
"""The modern login fixture yields its fake ``logintoken`` value."""
html = _load("login_page_modern.html")

assert (
DEFAULT_COMPATIBILITY.extract_login_token(html)
== "fake-logintoken-9f8a7b6c5d4e3f2a"
)


def test_extract_login_token_absent_returns_empty_string():
"""A page without a login form yields an empty login token."""
html = _load("dashboard_modern.html")

assert DEFAULT_COMPATIBILITY.extract_login_token(html) == ""


def test_extract_sesskey_from_config_page():
"""The ``M.cfg`` config fixture yields its fake sesskey value."""
html = _load("sesskey_config_modern.html")

assert DEFAULT_COMPATIBILITY.extract_sesskey(html) == "fakeSesskey1a2b3c"


def test_extract_sesskey_absent_returns_none():
"""A login page carrying no sesskey yields ``None``."""
html = _load("login_page_modern.html")

assert DEFAULT_COMPATIBILITY.extract_sesskey(html) is None


@pytest.mark.parametrize("strategy", [DEFAULT_COMPATIBILITY, LEGACY_COMPATIBILITY])
def test_extract_folder_filenames_is_stable_across_strategies(strategy):
"""Both strategies extract the same sorted filenames from the folder view.

This exercises the legacy/modern selector split: the fixture uses a
class-scoped ``pluginfile.php`` listing that both strategy selector orders
must resolve identically.
"""
soup = _soup("folder_view_modern.html")

assert strategy.extract_folder_filenames(soup) == [
"notes.txt",
"slides.pdf",
"syllabus.docx",
]


def test_extract_version_from_modern_dashboard():
"""The modern dashboard fixture parses to a 5.x ``MoodleVersion``."""
version = extract_version_from_dashboard(_load("dashboard_modern.html"))

assert version.major == 5
assert version.minor == 1
assert version.source == "html-meta"
assert version.raw.startswith("Moodle 5.1")


def test_extract_version_from_legacy_dashboard():
"""The legacy dashboard fixture parses to a 3.x ``MoodleVersion``."""
version = extract_version_from_dashboard(_load("dashboard_legacy.html"))

assert version.major == 3
assert version.minor == 11
assert version.patch == 16
assert version.source == "html-meta"


def test_extract_version_absent_returns_unknown():
"""A dashboard without a version marker degrades gracefully to unknown."""
version = extract_version_from_dashboard(_load("dashboard_no_version.html"))

assert version.major is None
assert version.raw == "unknown"


@pytest.mark.parametrize(
("fixture", "expected_major", "expected_strategy"),
[
("dashboard_legacy.html", 3, LegacyCompatibilityStrategy),
("dashboard_modern.html", 5, ModernCompatibilityStrategy),
],
)
def test_strategy_selection_matches_dashboard_version(
fixture, expected_major, expected_strategy
):
"""Version parsed from a dashboard picks the matching legacy/modern strategy."""
version = extract_version_from_dashboard(_load(fixture))

assert version.major == expected_major
assert isinstance(get_strategy_for_version(version), expected_strategy)