diff --git a/docs/development.md b/docs/development.md index 3688923..4c891e6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,27 +99,41 @@ def create_course(session: requests.Session, url: str, course_data: dict, token: ### Running Tests ```bash -# Run all tests -make test - -# Run specific test file -pytest tests/test_course.py +# Fast smoke tests that do not require Moodle +make test-unit +pytest tests/unit -# Run with coverage -pytest --cov=src/py_moodle tests/ +# Moodle-backed integration tests (opt in) +make test-local +make test-staging +pytest --integration --moodle-env local -m integration -n auto -# Run against different environments -make test-local # Local Moodle instance -make test-staging # Staging environment +# Full local workflow (starts Docker, then runs integration tests) +make test ``` ### Writing Tests - Tests go in the `tests/` directory +- Place fast, Moodle-free coverage in `tests/unit/` +- Integration tests outside `tests/unit/` are automatically marked with + `@pytest.mark.integration` and skipped unless `--integration` is passed - Use descriptive test names: `test_create_course_with_valid_data` - Test both success and failure cases - Use fixtures from `conftest.py` +### Troubleshooting Test Runs + +- `make test-unit` is the fastest way to confirm a change did not break the + smoke-test layer. +- If an integration run exits before collecting tests, verify the required + `MOODLE__URL`, `MOODLE__USERNAME`, and `MOODLE__PASSWORD` + variables exist in `.env`. +- If the `local` integration environment is unreachable, start it with + `docker compose up -d` or `make upd` before retrying. +- For authentication and session issues during test setup, see + [Troubleshooting](troubleshooting.md). + Example test: ```python diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..9eb87a1 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,81 @@ +# Troubleshooting + +This guide covers the most common authentication, session, and test setup +problems when working with `py-moodle`. + +## Authentication and Session Errors + +### `Moodle login failed: invalid username or password` + +- Verify `MOODLE_URL`, `MOODLE_USERNAME`, and `MOODLE_PASSWORD`. +- Confirm the Moodle site accepts direct login for that account. +- If the site uses CAS or another single sign-on flow, enable the matching CLI + settings before retrying. + +### `Authenticated to Moodle, but no webservice token or sesskey was available` + +- Confirm the user can open the Moodle dashboard in a browser after logging in. +- Enable the Moodle mobile web service, or provide a pre-configured token when + the site blocks automatic token creation. +- If the site uses CAS, confirm the session is returning to Moodle correctly + after authentication. + +### `Cannot call Moodle webservice ... without a webservice token` + +- Use a user that can access the Moodle mobile web service. +- Provide a pre-configured token through configuration when the site does not + allow token discovery. +- Prefer session-based helpers that accept a `sesskey` when a webservice token + is not available. + +### `Moodle login succeeded, but no sesskey was found on the dashboard` + +- Open the Moodle dashboard manually and confirm it loads after login. +- Check whether the site immediately redirects back to the login page or an SSO + prompt. +- Review reverse-proxy or CAS configuration if authenticated sessions are not + preserved. + +## Course Listing Errors + +### `Listing courses requires a valid webservice token or sesskey` + +- Re-authenticate so the session can refresh its `sesskey`. +- Use a Moodle account with permission to access the Moodle mobile web service + if you need the REST listing path. + +## Test Environment Issues + +### Integration tests are skipped unexpectedly + +Tests outside `tests/unit/` are marked as integration tests and are skipped +unless you pass `--integration`. + +```bash +pytest --integration --moodle-env local -m integration -n auto +``` + +### Pytest exits before collection because configuration is incomplete + +Add the required environment variables for the selected target to `.env`: + +- `MOODLE__URL` +- `MOODLE__USERNAME` +- `MOODLE__PASSWORD` + +For example, the local target uses `MOODLE_LOCAL_URL`, +`MOODLE_LOCAL_USERNAME`, and `MOODLE_LOCAL_PASSWORD`. + +### The local Moodle host is unreachable + +Start the local stack before retrying: + +```bash +make upd +``` + +or: + +```bash +docker compose up -d +``` diff --git a/mkdocs.yml b/mkdocs.yml index b87b7e4..8388848 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,4 +128,5 @@ nav: - User: api/user.md - Examples: examples.md - Development: development.md + - Troubleshooting: troubleshooting.md - Improvement Roadmap: roadmap-plan.md diff --git a/src/py_moodle/auth.py b/src/py_moodle/auth.py index 1585308..1676b2c 100644 --- a/src/py_moodle/auth.py +++ b/src/py_moodle/auth.py @@ -129,7 +129,11 @@ def _standard_login(self): print(f"[DEBUG] Response {resp.status_code} {resp.url}") # Authentication failed if redirected back to login page if "/login/index.php" in resp.url or "Invalid login" in resp.text: - raise LoginError("Invalid Moodle username or password.") + raise LoginError( + "Moodle login failed: invalid username or password. " + "Verify MOODLE_USERNAME and MOODLE_PASSWORD, and enable CAS " + "login if your site requires single sign-on." + ) def _cas_login(self): """ @@ -232,7 +236,11 @@ def _get_sesskey(self) -> str: resp = self.session.get(dashboard_url) sesskey = self.compatibility.extract_sesskey(resp.text) if not sesskey: - raise LoginError("Could not extract sesskey after login.") + raise LoginError( + "Moodle login succeeded, but no sesskey was found on the dashboard. " + "Confirm the account can open the site in a browser and that the " + "session is not being redirected back to the login page." + ) return sesskey def _get_webservice_token(self) -> Optional[str]: @@ -312,7 +320,8 @@ def enable_webservice( if resp.status_code != 200: raise LoginError( - "Failed to enable the webservice. Check user permissions and if you are logged in as admin." + "Failed to enable the Moodle webservice. Confirm the current user has " + "site administration permissions and that the session is still logged in." ) return True diff --git a/src/py_moodle/course.py b/src/py_moodle/course.py index ede54c7..7ba204a 100644 --- a/src/py_moodle/course.py +++ b/src/py_moodle/course.py @@ -121,7 +121,9 @@ def list_courses( if not sesskey: raise MoodleCourseError( - "No valid token or sesskey provided for listing courses." + "Listing courses requires a valid webservice token or sesskey. " + "Log in again, or use a user that can access the Moodle mobile " + "web service." ) url = f"{base_url}/lib/ajax/service.php?sesskey={sesskey}" diff --git a/src/py_moodle/session.py b/src/py_moodle/session.py index e43fc93..aa3baf0 100644 --- a/src/py_moodle/session.py +++ b/src/py_moodle/session.py @@ -74,8 +74,9 @@ def _login(self) -> None: # Validate we have at least one usable token if not self._token and not self._sesskey: raise MoodleSessionError( - "Could not obtain webservice token nor sesskey. " - "Check REST protocol permissions or CAS config." + "Authenticated to Moodle, but no webservice token or sesskey " + "was available. Confirm the Moodle mobile web service is " + "enabled for this user, or review CAS/session configuration." ) self._session = session @@ -121,8 +122,10 @@ def call( """Makes a call to the Moodle webservice API.""" if not self.token: raise LoginError( - "Cannot make a webservice call without a token. " - "Did you login correctly?" + "Cannot call Moodle webservice " + f"{wsfunction!r} without a webservice token. Use a pre-configured " + "token or log in with a user allowed to access the Moodle mobile " + "web service." ) if params is None: @@ -147,7 +150,8 @@ def call( "exception" in data or "errorcode" in data or "message" in data ): raise MoodleSessionError( - f"Moodle API error: {data.get('message', 'Unknown error')} " + f"Moodle API call {wsfunction!r} failed: " + f"{data.get('message', 'Unknown error')} " f"(errorcode: {data.get('errorcode', 'N/A')}, exception: {data.get('exception', 'N/A')})" ) return data diff --git a/tests/unit/test_error_messages.py b/tests/unit/test_error_messages.py new file mode 100644 index 0000000..ee4a447 --- /dev/null +++ b/tests/unit/test_error_messages.py @@ -0,0 +1,162 @@ +"""Unit tests for user-facing error wording.""" + +import pytest + +from py_moodle.auth import LoginError, MoodleAuth +from py_moodle.course import MoodleCourseError, list_courses +from py_moodle.session import MoodleSession, MoodleSessionError +from py_moodle.settings import Settings + + +class StubResponse: + """Minimal HTTP response object for error-message tests.""" + + def __init__(self, *, text="", url="https://moodle.example.test/", json_data=None): + self.text = text + self.url = url + self.json_data = json_data + self.status_code = 200 + + def json(self): + """Return the configured JSON payload.""" + return self.json_data + + def raise_for_status(self): + """Mirror the requests.Response API for successful responses.""" + return None + + +class StubSession: + """Minimal session object for deterministic unit tests.""" + + def __init__(self, *, get_response=None, post_response=None): + self.get_response = get_response or StubResponse() + self.post_response = post_response or StubResponse(json_data={}) + self.sesskey = None + self.webservice_token = None + + def get(self, url, **kwargs): + """Return the canned GET response.""" + return self.get_response + + def post(self, url, **kwargs): + """Return the canned POST response.""" + return self.post_response + + +class StubCompatibility: + """Minimal compatibility helper that never finds a sesskey.""" + + @staticmethod + def extract_sesskey(text): + """Return no sesskey for the provided HTML payload.""" + return None + + +def build_settings(): + """Create a minimal settings object for session tests.""" + return Settings( + env_name="local", + url="https://moodle.example.test", + username="user", + password="secret", + use_cas=False, + cas_url=None, + webservice_token=None, + ) + + +def test_standard_login_error_mentions_credentials_and_cas(): + """Invalid login errors should point users to credentials and CAS settings.""" + auth = MoodleAuth( + base_url="https://moodle.example.test", + username="user", + password="wrong", + ) + auth.compatibility = type( + "Compat", + (), + {"extract_login_token": staticmethod(lambda text: "token")}, + )() + auth.session = StubSession( + get_response=StubResponse(text=""), + post_response=StubResponse( + text="Invalid login", + url="https://moodle.example.test/login/index.php", + ), + ) + + with pytest.raises(LoginError) as excinfo: + auth._standard_login() + + message = str(excinfo.value) + assert "invalid username or password" in message + assert "MOODLE_USERNAME" in message + assert "CAS" in message + + +def test_session_login_error_mentions_webservice_and_cas(monkeypatch): + """Missing token and sesskey errors should point to the likely fixes.""" + stub_session = StubSession(get_response=StubResponse(text="")) + monkeypatch.setattr("py_moodle.session.login", lambda *args, **kwargs: stub_session) + monkeypatch.setattr( + "py_moodle.session.get_session_compatibility", + lambda session: StubCompatibility(), + ) + + moodle_session = MoodleSession(build_settings()) + + with pytest.raises(MoodleSessionError) as excinfo: + moodle_session._login() + + message = str(excinfo.value) + assert "no webservice token or sesskey was available" in message + assert "Moodle mobile web service" in message + assert "CAS/session configuration" in message + + +def test_session_call_without_token_mentions_wsfunction(): + """Missing-token errors should explain how to restore webservice access.""" + moodle_session = MoodleSession(build_settings()) + moodle_session._session = StubSession() + + with pytest.raises(LoginError) as excinfo: + moodle_session.call("core_webservice_get_site_info") + + message = str(excinfo.value) + assert "core_webservice_get_site_info" in message + assert "pre-configured token" in message + assert "Moodle mobile web service" in message + + +def test_session_call_api_error_mentions_wsfunction(): + """API errors should include the failing Moodle webservice function name.""" + moodle_session = MoodleSession(build_settings()) + moodle_session._session = StubSession( + post_response=StubResponse( + json_data={ + "message": "Access control exception", + "errorcode": "accessexception", + "exception": "required_capability_exception", + } + ) + ) + moodle_session._token = "token" + + with pytest.raises(MoodleSessionError) as excinfo: + moodle_session.call("core_course_get_courses") + + message = str(excinfo.value) + assert "core_course_get_courses" in message + assert "Access control exception" in message + assert "accessexception" in message + + +def test_list_courses_error_mentions_token_or_sesskey(): + """Course-listing errors should explain which session credentials are missing.""" + with pytest.raises(MoodleCourseError) as excinfo: + list_courses(object(), "https://moodle.example.test") + + message = str(excinfo.value) + assert "valid webservice token or sesskey" in message + assert "Moodle mobile web service" in message