Skip to content

Commit 62d858a

Browse files
Copiloterseco
andcommitted
docs: add troubleshooting guide for roadmap task
Co-authored-by: erseco <1876752+erseco@users.noreply.github.com>
1 parent a8d6ca5 commit 62d858a

7 files changed

Lines changed: 292 additions & 19 deletions

File tree

docs/development.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,27 +99,41 @@ def create_course(session: requests.Session, url: str, course_data: dict, token:
9999
### Running Tests
100100

101101
```bash
102-
# Run all tests
103-
make test
104-
105-
# Run specific test file
106-
pytest tests/test_course.py
102+
# Fast smoke tests that do not require Moodle
103+
make test-unit
104+
pytest tests/unit
107105

108-
# Run with coverage
109-
pytest --cov=src/py_moodle tests/
106+
# Moodle-backed integration tests (opt in)
107+
make test-local
108+
make test-staging
109+
pytest --integration --moodle-env local -m integration -n auto
110110

111-
# Run against different environments
112-
make test-local # Local Moodle instance
113-
make test-staging # Staging environment
111+
# Full local workflow (starts Docker, then runs integration tests)
112+
make test
114113
```
115114

116115
### Writing Tests
117116

118117
- Tests go in the `tests/` directory
118+
- Place fast, Moodle-free coverage in `tests/unit/`
119+
- Integration tests outside `tests/unit/` are automatically marked with
120+
`@pytest.mark.integration` and skipped unless `--integration` is passed
119121
- Use descriptive test names: `test_create_course_with_valid_data`
120122
- Test both success and failure cases
121123
- Use fixtures from `conftest.py`
122124

125+
### Troubleshooting Test Runs
126+
127+
- `make test-unit` is the fastest way to confirm a change did not break the
128+
smoke-test layer.
129+
- If an integration run exits before collecting tests, verify the required
130+
`MOODLE_<ENV>_URL`, `MOODLE_<ENV>_USERNAME`, and `MOODLE_<ENV>_PASSWORD`
131+
variables exist in `.env`.
132+
- If the `local` integration environment is unreachable, start it with
133+
`docker compose up -d` or `make upd` before retrying.
134+
- For authentication and session issues during test setup, see
135+
[Troubleshooting](troubleshooting.md).
136+
123137
Example test:
124138

125139
```python

docs/troubleshooting.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Troubleshooting
2+
3+
This guide covers the most common authentication, session, and test setup
4+
problems when working with `py-moodle`.
5+
6+
## Authentication and Session Errors
7+
8+
### `Moodle login failed: invalid username or password`
9+
10+
- Verify `MOODLE_URL`, `MOODLE_USERNAME`, and `MOODLE_PASSWORD`.
11+
- Confirm the Moodle site accepts direct login for that account.
12+
- If the site uses CAS or another single sign-on flow, enable the matching CLI
13+
settings before retrying.
14+
15+
### `Authenticated to Moodle, but no webservice token or sesskey was available`
16+
17+
- Confirm the user can open the Moodle dashboard in a browser after logging in.
18+
- Enable the Moodle mobile web service, or provide a pre-configured token when
19+
the site blocks automatic token creation.
20+
- If the site uses CAS, confirm the session is returning to Moodle correctly
21+
after authentication.
22+
23+
### `Cannot call Moodle webservice ... without a webservice token`
24+
25+
- Use a user that can access the Moodle mobile web service.
26+
- Provide a pre-configured token through configuration when the site does not
27+
allow token discovery.
28+
- Prefer session-based helpers that accept a `sesskey` when a webservice token
29+
is not available.
30+
31+
### `Moodle login succeeded, but no sesskey was found on the dashboard`
32+
33+
- Open the Moodle dashboard manually and confirm it loads after login.
34+
- Check whether the site immediately redirects back to the login page or an SSO
35+
prompt.
36+
- Review reverse-proxy or CAS configuration if authenticated sessions are not
37+
preserved.
38+
39+
## Course Listing Errors
40+
41+
### `Listing courses requires a valid webservice token or sesskey`
42+
43+
- Re-authenticate so the session can refresh its `sesskey`.
44+
- Use a Moodle account with permission to access the Moodle mobile web service
45+
if you need the REST listing path.
46+
47+
## Test Environment Issues
48+
49+
### Integration tests are skipped unexpectedly
50+
51+
Tests outside `tests/unit/` are marked as integration tests and are skipped
52+
unless you pass `--integration`.
53+
54+
```bash
55+
pytest --integration --moodle-env local -m integration -n auto
56+
```
57+
58+
### Pytest exits before collection because configuration is incomplete
59+
60+
Add the required environment variables for the selected target to `.env`:
61+
62+
- `MOODLE_<ENV>_URL`
63+
- `MOODLE_<ENV>_USERNAME`
64+
- `MOODLE_<ENV>_PASSWORD`
65+
66+
For example, the local target uses `MOODLE_LOCAL_URL`,
67+
`MOODLE_LOCAL_USERNAME`, and `MOODLE_LOCAL_PASSWORD`.
68+
69+
### The local Moodle host is unreachable
70+
71+
Start the local stack before retrying:
72+
73+
```bash
74+
make upd
75+
```
76+
77+
or:
78+
79+
```bash
80+
docker compose up -d
81+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,5 @@ nav:
128128
- User: api/user.md
129129
- Examples: examples.md
130130
- Development: development.md
131+
- Troubleshooting: troubleshooting.md
131132
- Improvement Roadmap: roadmap-plan.md

src/py_moodle/auth.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ def _standard_login(self):
129129
print(f"[DEBUG] Response {resp.status_code} {resp.url}")
130130
# Authentication failed if redirected back to login page
131131
if "/login/index.php" in resp.url or "Invalid login" in resp.text:
132-
raise LoginError("Invalid Moodle username or password.")
132+
raise LoginError(
133+
"Moodle login failed: invalid username or password. "
134+
"Verify MOODLE_USERNAME and MOODLE_PASSWORD, and enable CAS "
135+
"login if your site requires single sign-on."
136+
)
133137

134138
def _cas_login(self):
135139
"""
@@ -232,7 +236,11 @@ def _get_sesskey(self) -> str:
232236
resp = self.session.get(dashboard_url)
233237
sesskey = self.compatibility.extract_sesskey(resp.text)
234238
if not sesskey:
235-
raise LoginError("Could not extract sesskey after login.")
239+
raise LoginError(
240+
"Moodle login succeeded, but no sesskey was found on the dashboard. "
241+
"Confirm the account can open the site in a browser and that the "
242+
"session is not being redirected back to the login page."
243+
)
236244
return sesskey
237245

238246
def _get_webservice_token(self) -> Optional[str]:
@@ -312,7 +320,8 @@ def enable_webservice(
312320

313321
if resp.status_code != 200:
314322
raise LoginError(
315-
"Failed to enable the webservice. Check user permissions and if you are logged in as admin."
323+
"Failed to enable the Moodle webservice. Confirm the current user has "
324+
"site administration permissions and that the session is still logged in."
316325
)
317326

318327
return True

src/py_moodle/course.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ def list_courses(
121121

122122
if not sesskey:
123123
raise MoodleCourseError(
124-
"No valid token or sesskey provided for listing courses."
124+
"Listing courses requires a valid webservice token or sesskey. "
125+
"Log in again, or use a user that can access the Moodle mobile "
126+
"web service."
125127
)
126128
url = f"{base_url}/lib/ajax/service.php?sesskey={sesskey}"
127129

src/py_moodle/session.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ def _login(self) -> None:
7474
# Validate we have at least one usable token
7575
if not self._token and not self._sesskey:
7676
raise MoodleSessionError(
77-
"Could not obtain webservice token nor sesskey. "
78-
"Check REST protocol permissions or CAS config."
77+
"Authenticated to Moodle, but no webservice token or sesskey "
78+
"was available. Confirm the Moodle mobile web service is "
79+
"enabled for this user, or review CAS/session configuration."
7980
)
8081

8182
self._session = session
@@ -121,8 +122,10 @@ def call(
121122
"""Makes a call to the Moodle webservice API."""
122123
if not self.token:
123124
raise LoginError(
124-
"Cannot make a webservice call without a token. "
125-
"Did you login correctly?"
125+
"Cannot call Moodle webservice "
126+
f"{wsfunction!r} without a webservice token. Use a pre-configured "
127+
"token or log in with a user allowed to access the Moodle mobile "
128+
"web service."
126129
)
127130

128131
if params is None:
@@ -147,7 +150,8 @@ def call(
147150
"exception" in data or "errorcode" in data or "message" in data
148151
):
149152
raise MoodleSessionError(
150-
f"Moodle API error: {data.get('message', 'Unknown error')} "
153+
f"Moodle API call {wsfunction!r} failed: "
154+
f"{data.get('message', 'Unknown error')} "
151155
f"(errorcode: {data.get('errorcode', 'N/A')}, exception: {data.get('exception', 'N/A')})"
152156
)
153157
return data

tests/unit/test_error_messages.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Unit tests for user-facing error wording."""
2+
3+
import pytest
4+
5+
from py_moodle.auth import LoginError, MoodleAuth
6+
from py_moodle.course import MoodleCourseError, list_courses
7+
from py_moodle.session import MoodleSession, MoodleSessionError
8+
from py_moodle.settings import Settings
9+
10+
11+
class FakeResponse:
12+
"""Minimal HTTP response object for error-message tests."""
13+
14+
def __init__(self, *, text="", url="https://moodle.example.test/", json_data=None):
15+
self.text = text
16+
self.url = url
17+
self.json_data = json_data
18+
self.status_code = 200
19+
20+
def json(self):
21+
"""Return the configured JSON payload."""
22+
return self.json_data
23+
24+
def raise_for_status(self):
25+
"""Mirror the requests.Response API for successful responses."""
26+
return None
27+
28+
29+
class FakeSession:
30+
"""Minimal session object for deterministic unit tests."""
31+
32+
def __init__(self, *, get_response=None, post_response=None):
33+
self.get_response = get_response or FakeResponse()
34+
self.post_response = post_response or FakeResponse(json_data={})
35+
self.sesskey = None
36+
self.webservice_token = None
37+
38+
def get(self, url, **kwargs):
39+
"""Return the canned GET response."""
40+
return self.get_response
41+
42+
def post(self, url, **kwargs):
43+
"""Return the canned POST response."""
44+
return self.post_response
45+
46+
47+
class FakeCompatibility:
48+
"""Minimal compatibility helper that never finds a sesskey."""
49+
50+
@staticmethod
51+
def extract_sesskey(text):
52+
"""Return no sesskey for the provided HTML payload."""
53+
return None
54+
55+
56+
def build_settings():
57+
"""Create a minimal settings object for session tests."""
58+
return Settings(
59+
env_name="local",
60+
url="https://moodle.example.test",
61+
username="user",
62+
password="secret",
63+
use_cas=False,
64+
cas_url=None,
65+
webservice_token=None,
66+
)
67+
68+
69+
def test_standard_login_error_mentions_credentials_and_cas():
70+
"""Invalid login errors should point users to credentials and CAS settings."""
71+
auth = MoodleAuth(
72+
base_url="https://moodle.example.test",
73+
username="user",
74+
password="wrong",
75+
)
76+
auth.compatibility = type(
77+
"Compat",
78+
(),
79+
{"extract_login_token": staticmethod(lambda text: "token")},
80+
)()
81+
auth.session = FakeSession(
82+
get_response=FakeResponse(text="<input name='logintoken' value='token'>"),
83+
post_response=FakeResponse(
84+
text="Invalid login",
85+
url="https://moodle.example.test/login/index.php",
86+
),
87+
)
88+
89+
with pytest.raises(LoginError) as excinfo:
90+
auth._standard_login()
91+
92+
message = str(excinfo.value)
93+
assert "invalid username or password" in message
94+
assert "MOODLE_USERNAME" in message
95+
assert "CAS" in message
96+
97+
98+
def test_session_login_error_mentions_webservice_and_cas(monkeypatch):
99+
"""Missing token and sesskey errors should point to the likely fixes."""
100+
fake_session = FakeSession(get_response=FakeResponse(text="<html></html>"))
101+
monkeypatch.setattr("py_moodle.session.login", lambda *args, **kwargs: fake_session)
102+
monkeypatch.setattr(
103+
"py_moodle.session.get_session_compatibility",
104+
lambda session: FakeCompatibility(),
105+
)
106+
107+
moodle_session = MoodleSession(build_settings())
108+
109+
with pytest.raises(MoodleSessionError) as excinfo:
110+
moodle_session._login()
111+
112+
message = str(excinfo.value)
113+
assert "no webservice token or sesskey was available" in message
114+
assert "Moodle mobile web service" in message
115+
assert "CAS/session configuration" in message
116+
117+
118+
def test_session_call_without_token_mentions_wsfunction():
119+
"""Missing-token errors should explain how to restore webservice access."""
120+
moodle_session = MoodleSession(build_settings())
121+
moodle_session._session = FakeSession()
122+
123+
with pytest.raises(LoginError) as excinfo:
124+
moodle_session.call("core_webservice_get_site_info")
125+
126+
message = str(excinfo.value)
127+
assert "core_webservice_get_site_info" in message
128+
assert "pre-configured token" in message
129+
assert "Moodle mobile web service" in message
130+
131+
132+
def test_session_call_api_error_mentions_wsfunction():
133+
"""API errors should include the failing Moodle webservice function name."""
134+
moodle_session = MoodleSession(build_settings())
135+
moodle_session._session = FakeSession(
136+
post_response=FakeResponse(
137+
json_data={
138+
"message": "Access control exception",
139+
"errorcode": "accessexception",
140+
"exception": "required_capability_exception",
141+
}
142+
)
143+
)
144+
moodle_session._token = "token"
145+
146+
with pytest.raises(MoodleSessionError) as excinfo:
147+
moodle_session.call("core_course_get_courses")
148+
149+
message = str(excinfo.value)
150+
assert "core_course_get_courses" in message
151+
assert "Access control exception" in message
152+
assert "accessexception" in message
153+
154+
155+
def test_list_courses_error_mentions_token_or_sesskey():
156+
"""Course-listing errors should explain which session credentials are missing."""
157+
with pytest.raises(MoodleCourseError) as excinfo:
158+
list_courses(object(), "https://moodle.example.test")
159+
160+
message = str(excinfo.value)
161+
assert "valid webservice token or sesskey" in message
162+
assert "Moodle mobile web service" in message

0 commit comments

Comments
 (0)