diff --git a/src/py_moodle/transport/webservice.py b/src/py_moodle/transport/webservice.py index d3d496d..79e9eb1 100644 --- a/src/py_moodle/transport/webservice.py +++ b/src/py_moodle/transport/webservice.py @@ -14,10 +14,22 @@ from ..http import MoodleHttpError, MoodleWebserviceError, request_webservice from . import TransportError, TransportUnavailableError -#: Substring (case-insensitive) that identifies an invalid/expired token -#: Moodle exception, distinguishing "try another transport" from a genuine -#: webservice-level failure. -_INVALID_TOKEN_MARKER = "invalid token" +#: Substrings (case-insensitive) identifying a Moodle webservice failure +#: that means "this call path isn't usable right now, try another +#: transport" rather than a genuine webservice-level failure worth +#: surfacing immediately: +#: - an invalid/expired token; +#: - a webservice-side context-validation failure (observed in practice +#: as "You cannot execute functions in the course context (course +#: id:N). ... Context does not exist"), e.g. when the target course's +#: context is momentarily stale relative to the calling user's cached +#: session state. The AJAX transport re-derives context from the +#: current page/session instead of a cached webservice-side lookup, +#: so it is not subject to the same failure. +_TRANSPORT_UNAVAILABLE_MARKERS = ( + "invalid token", + "cannot execute functions in the course context", +) def call( @@ -41,15 +53,17 @@ def call( Raises: TransportUnavailableError: If Moodle reports an invalid/expired - token, indicating the caller should fall back to another - transport. + token, or a context-validation failure, indicating the caller + should fall back to another transport (see + :data:`_TRANSPORT_UNAVAILABLE_MARKERS`). TransportError: If the call fails for any other reason (network - error, non-token Moodle exception, invalid JSON). + error, non-token/context Moodle exception, invalid JSON). """ try: return request_webservice(session, base_url, wsfunction, params, token=token) except MoodleWebserviceError as exc: - if _INVALID_TOKEN_MARKER in str(exc).lower(): + message = str(exc).lower() + if any(marker in message for marker in _TRANSPORT_UNAVAILABLE_MARKERS): raise TransportUnavailableError(str(exc)) from exc raise TransportError(str(exc)) from exc except MoodleHttpError as exc: diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py index 8bb4b09..227bdc0 100644 --- a/tests/unit/test_transport.py +++ b/tests/unit/test_transport.py @@ -83,6 +83,37 @@ def test_webservice_call_raises_transport_unavailable_on_invalid_token(): ) +def test_webservice_call_raises_transport_unavailable_on_context_error(): + """A 'cannot execute functions in the course context' error signals fallback. + + Regression test for a Moodle webservice quirk observed in CI: a call to + a generic, non-course-scoped function like ``core_course_get_courses`` + can still fail with a stale/invalid course-context validation error + unrelated to the token itself. This must be treated the same as an + invalid token -- fall back to another transport -- rather than + surfacing immediately, since the AJAX transport re-derives context from + the current session instead of a cached webservice-side lookup. + """ + session = StubSession( + post_result=StubResponse( + json_data={ + "exception": "invalid_parameter_exception", + "errorcode": "invalidparameter", + "message": ( + "You cannot execute functions in the course context " + "(course id:8). The context error message was: Invalid " + "parameter value detected (Context does not exist)" + ), + } + ) + ) + + with pytest.raises(TransportUnavailableError): + webservice_transport.call( + session, BASE_URL, "core_course_get_courses", FAKE_TOKEN + ) + + def test_webservice_call_raises_transport_error_on_other_moodle_exception(): """A non-token Moodle exception raises a plain TransportError.""" session = StubSession( @@ -177,6 +208,37 @@ def test_list_courses_falls_back_to_ajax_on_invalid_token(): assert result == [{"id": 1}, {"id": 2}] +def test_list_courses_falls_back_to_ajax_on_context_error(): + """list_courses() falls back to AJAX on a course-context validation error. + + Same regression as test_webservice_call_raises_transport_unavailable_on_context_error, + exercised through the public list_courses() entry point. + """ + session = StubSession( + get_result=StubResponse(text=""), + post_dispatch={ + f"{BASE_URL}/webservice/rest/server.php": StubResponse( + json_data={ + "exception": "invalid_parameter_exception", + "errorcode": "invalidparameter", + "message": ( + "You cannot execute functions in the course context " + "(course id:8). The context error message was: Invalid " + "parameter value detected (Context does not exist)" + ), + } + ), + f"{BASE_URL}/lib/ajax/service.php": StubResponse( + json_data=[{"error": False, "data": [{"id": 2}, {"id": 1}]}] + ), + }, + ) + + result = list_courses(session, BASE_URL, token=FAKE_TOKEN, sesskey=FAKE_SESSKEY) + + assert result == [{"id": 1}, {"id": 2}] + + def test_list_courses_raises_when_neither_transport_usable(): """list_courses() raises a clear error with no token and no sesskey.""" session = StubSession()