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
34 changes: 24 additions & 10 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ENV>_URL`, `MOODLE_<ENV>_USERNAME`, and `MOODLE_<ENV>_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
Expand Down
81 changes: 81 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -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_<ENV>_URL`
- `MOODLE_<ENV>_USERNAME`
- `MOODLE_<ENV>_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
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,5 @@ nav:
- User: api/user.md
- Examples: examples.md
- Development: development.md
- Troubleshooting: troubleshooting.md
- Improvement Roadmap: roadmap-plan.md
15 changes: 12 additions & 3 deletions src/py_moodle/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/py_moodle/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
14 changes: 9 additions & 5 deletions src/py_moodle/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
162 changes: 162 additions & 0 deletions tests/unit/test_error_messages.py
Original file line number Diff line number Diff line change
@@ -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="<input name='logintoken' value='token'>"),
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="<html></html>"))
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
Loading