diff --git a/docs/api/ensure.md b/docs/api/ensure.md new file mode 100644 index 0000000..e803c15 --- /dev/null +++ b/docs/api/ensure.md @@ -0,0 +1,8 @@ +# Ensure + +Idempotent, ensure-style provisioning helpers for sections and content +modules. Each helper looks up an existing object by its human-readable natural +key and only creates a new one when nothing matches, mirroring +[`ensure_course`](course.md). + +::: py_moodle.ensure diff --git a/docs/recipes.md b/docs/recipes.md index dbe10a0..f019aa2 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -281,6 +281,57 @@ py-moodle courses ensure \ `--update` only ever touches `fullname` and category membership; it never overwrites the course summary, visibility, or its sections/modules. +## Idempotent content provisioning + +The `py_moodle.ensure` module extends the same create-or-reuse pattern to +sections and content modules, so a provisioning script can be re-run safely +without creating duplicate sections, labels, resources, or folders. Each +helper keys on a human-readable natural key: sections on their `name`, and +modules on the `(name, modname)` pair within the course. + +```python +from py_moodle import MoodleSession +from py_moodle.course import create_or_update_course +from py_moodle.ensure import ensure_label, ensure_section + +ms = MoodleSession.get() +session, url, sesskey = ms.session, ms.settings.url, ms.sesskey + +# Create the course if missing, or bring its fullname/category in line. +course = create_or_update_course( + session, + url, + sesskey, + shortname="ci-smoke-test", + fullname="CI Smoke Test", + category_id=1, +).course + +# Ensure a named section exists (created and renamed only when missing). +section = ensure_section( + session, url, sesskey, course["id"], name="Welcome" +) + +# Ensure a welcome label exists in that section; a second run reuses it. +label = ensure_label( + session, + url, + sesskey, + course["id"], + section.section["id"], + name="Welcome message", + html="

Welcome to the course!

", +) + +print(section.status, label.status) # e.g. "created created", then "reused reused" +``` + +`ensure_resource` and `ensure_folder` work the same way, forwarding their +entity-specific arguments (`file_path`, `files_itemid`, ...) to the underlying +`add_*` primitives only when a matching module does not already exist. Because +the idempotency keys are human names, renaming a section or module in Moodle +makes the next run treat it as absent and re-create it -- pick stable names. + ## Get IDE-friendly typed models from raw dicts (optional) The library functions keep returning plain `dict`/`list[dict]` values, but diff --git a/mkdocs.yml b/mkdocs.yml index 81b04d8..02cc2be 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,6 +119,7 @@ nav: - Course: api/course.md - Category: api/category.md - Section: api/section.md + - Ensure: api/ensure.md - Content Modules: - Module: api/module.md - Label: api/label.md diff --git a/src/py_moodle/course.py b/src/py_moodle/course.py index 8a2ea65..96fe5b8 100644 --- a/src/py_moodle/course.py +++ b/src/py_moodle/course.py @@ -622,6 +622,55 @@ def ensure_course( return EnsureCourseResult(status="updated", course=updated_course) +def create_or_update_course( + session: requests.Session, + base_url: str, + sesskey: str, + *, + shortname: str, + fullname: str, + category_id: int, + token: str | None = None, + **create_kwargs: Any, +) -> EnsureCourseResult: + """Create a course, or update it if one with the shortname already exists. + + Convenience wrapper over :func:`ensure_course` with ``update=True``: if a + course with ``shortname`` exists it is updated in place (differing + ``fullname``/``category_id`` fields are applied) instead of reporting a + conflict; otherwise a new course is created. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + shortname: Unique shortname used to look up an existing course. + fullname: Desired full name of the course. + category_id: Desired category ID of the course. + token: Optional webservice token, forwarded to ``list_courses``. + **create_kwargs: Extra keyword arguments forwarded to + ``create_course`` when a new course must be created. + + Returns: + EnsureCourseResult: Typed result with ``status`` of ``"created"``, + ``"reused"``, or ``"updated"``. + + Raises: + MoodleCourseError: If listing, creating, or updating fails. + """ + return ensure_course( + session, + base_url, + sesskey, + shortname=shortname, + fullname=fullname, + category_id=category_id, + token=token, + update=True, + **create_kwargs, + ) + + def delete_course( session: requests.Session, base_url: str, @@ -890,6 +939,7 @@ def list_sections(course_contents: List[Dict[str, Any]]) -> List[Dict[str, Any]] "list_courses", "create_course", "ensure_course", + "create_or_update_course", "update_course_basic", "delete_course", "get_course", diff --git a/src/py_moodle/ensure.py b/src/py_moodle/ensure.py new file mode 100644 index 0000000..c61022a --- /dev/null +++ b/src/py_moodle/ensure.py @@ -0,0 +1,431 @@ +"""Idempotent ensure-style provisioning helpers for Moodle content. + +This module extends the ``ensure_course`` pattern (see +:mod:`py_moodle.course`) to sections and content modules. Every helper is +*idempotent*: it first looks up an existing object by its human-readable +natural key and only creates a new one when nothing matches, mirroring how +``ensure_course`` keys on ``shortname``. + +The idempotency keys are: + +* :func:`ensure_module` (and its wrappers): ``(name, modname)`` within the + course. +* :func:`ensure_section`: the section ``name`` within the course. + +These helpers only ever *create or reuse*; they never delete or reconcile +away extra objects. The heavy lookup +(``get_course_with_sections_and_modules``) and the mutating primitives +(``add_label``/``add_resource``/``add_folder``/``create_section``) are imported +lazily inside the functions to avoid import cycles and to keep the module easy +to test with monkeypatching. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Callable, Dict, Literal, Optional + +import requests + +from .section import MoodleSectionError + +EnsureModuleStatus = Literal["created", "reused"] +EnsureSectionStatus = Literal["created", "reused"] + + +@dataclass +class EnsureModuleResult: + """Outcome of an :func:`ensure_module` call. + + Attributes: + status: ``"created"`` when a new module was created, ``"reused"`` + when an existing module matched the ``(name, modname)`` key. + cmid: The course module ID of the resulting module. + module: The matched module dictionary when ``status == "reused"``, + or ``None`` when a new module was created. + """ + + status: EnsureModuleStatus + cmid: int + module: Optional[Dict[str, Any]] = None + + +@dataclass +class EnsureSectionResult: + """Outcome of an :func:`ensure_section` call. + + Attributes: + status: ``"created"`` when a new section was created and renamed, + ``"reused"`` when an existing section matched by ``name``. + section: The resulting section dictionary. When ``status == + "reused"`` this is the section as returned by + ``get_course_with_sections_and_modules``; when ``status == + "created"`` it is a minimal ``{"id": ..., "name": ...}`` mapping. + """ + + status: EnsureSectionStatus + section: Dict[str, Any] + + +def ensure_module( + session: requests.Session, + base_url: str, + sesskey: str, + course_id: int, + section_id: int, + *, + name: str, + modname: str, + create_fn: Callable[[], int], + token: Optional[str] = None, +) -> EnsureModuleResult: + """Ensure a module with the given name and type exists in the course. + + Scans every section of the course for a module whose ``name`` and + ``modname`` both match. If found, the module is reused and ``create_fn`` + is never called. Otherwise ``create_fn`` is invoked exactly once (it must + create the module and return its ``cmid``) and the result is ``created``. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + course_id: ID of the course to search within. + section_id: ID of the section the module belongs to (passed by the + wrappers to their ``create_fn``; not used for the lookup, which is + course-wide, mirroring ``ensure_course``). + name: Human-readable module name used as part of the idempotency key. + modname: Moodle module type (e.g. ``"label"``) used as part of the + idempotency key. + create_fn: Zero-argument callable that creates the module and returns + its ``cmid``. Only called when no existing module matches. + token: Optional webservice token forwarded to the course lookup. + + Returns: + EnsureModuleResult: Typed result with ``status`` of ``"created"`` or + ``"reused"``. + """ + from py_moodle.course import get_course_with_sections_and_modules + + course = get_course_with_sections_and_modules( + session, base_url, sesskey, course_id, token=token + ) + for section in course.get("sections", []): + for module in section.get("modules", []): + if module.get("name") == name and module.get("modname") == modname: + return EnsureModuleResult( + status="reused", cmid=module.get("id"), module=module + ) + + cmid = create_fn() + return EnsureModuleResult(status="created", cmid=cmid, module=None) + + +def ensure_label( + session: requests.Session, + base_url: str, + sesskey: str, + course_id: int, + section_id: int, + *, + name: str, + html: str, + visible: int = 1, + token: Optional[str] = None, +) -> EnsureModuleResult: + """Ensure a ``label`` module with the given name exists. + + Thin wrapper over :func:`ensure_module` binding ``modname="label"`` and + delegating creation to :func:`py_moodle.label.add_label`. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + course_id: ID of the course. + section_id: ID of the section to create the label in when missing. + name: Label name (idempotency key together with the module type). + html: HTML content of the label. + visible: Whether the label is visible (``1``) or hidden (``0``). + token: Optional webservice token forwarded to the course lookup. + + Returns: + EnsureModuleResult: Result of the create-or-reuse operation. + """ + + def create_fn() -> int: + from py_moodle.label import add_label + + return add_label( + session, + base_url, + sesskey, + course_id, + section_id, + html, + name=name, + visible=visible, + ) + + return ensure_module( + session, + base_url, + sesskey, + course_id, + section_id, + name=name, + modname="label", + create_fn=create_fn, + token=token, + ) + + +def ensure_resource( + session: requests.Session, + base_url: str, + sesskey: str, + course_id: int, + section_id: int, + *, + name: str, + file_path: str, + intro: str = "", + visible: int = 1, + token: Optional[str] = None, +) -> EnsureModuleResult: + """Ensure a ``resource`` (single-file) module with the given name exists. + + Thin wrapper over :func:`ensure_module` binding ``modname="resource"`` and + delegating creation to :func:`py_moodle.resource.add_resource`. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + course_id: ID of the course. + section_id: ID of the section to create the resource in when missing. + name: Resource name (idempotency key together with the module type). + file_path: Local path to the file to upload when creating. + intro: Optional HTML introduction for the resource. + visible: Whether the resource is visible (``1``) or hidden (``0``). + token: Optional webservice token forwarded to the course lookup. + + Returns: + EnsureModuleResult: Result of the create-or-reuse operation. + """ + + def create_fn() -> int: + from py_moodle.resource import add_resource + + return add_resource( + session, + base_url, + sesskey, + course_id, + section_id, + name, + file_path, + intro=intro, + visible=visible, + ) + + return ensure_module( + session, + base_url, + sesskey, + course_id, + section_id, + name=name, + modname="resource", + create_fn=create_fn, + token=token, + ) + + +def ensure_folder( + session: requests.Session, + base_url: str, + sesskey: str, + course_id: int, + section_id: int, + *, + name: str, + files_itemid: int, + intro_html: str = "", + visible: int = 1, + token: Optional[str] = None, +) -> EnsureModuleResult: + """Ensure a ``folder`` module with the given name exists. + + Thin wrapper over :func:`ensure_module` binding ``modname="folder"`` and + delegating creation to :func:`py_moodle.folder.add_folder`. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + course_id: ID of the course. + section_id: ID of the section to create the folder in when missing. + name: Folder name (idempotency key together with the module type). + files_itemid: Draft-area itemid holding the folder's files. + intro_html: Optional HTML introduction for the folder. + visible: Whether the folder is visible (``1``) or hidden (``0``). + token: Optional webservice token forwarded to the course lookup. + + Returns: + EnsureModuleResult: Result of the create-or-reuse operation. + """ + + def create_fn() -> int: + from py_moodle.folder import add_folder + + return add_folder( + session, + base_url, + sesskey, + course_id, + section_id, + name, + files_itemid, + intro_html=intro_html, + visible=visible, + ) + + return ensure_module( + session, + base_url, + sesskey, + course_id, + section_id, + name=name, + modname="folder", + create_fn=create_fn, + token=token, + ) + + +def _rename_section( + session: requests.Session, + base_url: str, + sesskey: str, + section_id: int, + name: str, +) -> bool: + """Rename a course section via the ``inplace_editable`` AJAX endpoint. + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for the AJAX call. + section_id: ID of the section to rename. + name: New name for the section. + + Returns: + bool: ``True`` when Moodle confirms the rename. + + Raises: + MoodleSectionError: If the AJAX call fails or returns an error. + + Note: + Section renaming is served by the active course format's + ``inplace_editable`` handler. This helper targets the standard + ``sectionname`` item type provided by Moodle's built-in + topics/weeks formats (the default for courses created through this + library); custom course formats that do not implement it are not + supported. + """ + ajax_url = ( + f"{base_url}/lib/ajax/service.php?sesskey={sesskey}" + "&info=core_update_inplace_editable" + ) + payload = [ + { + "index": 0, + "methodname": "core_update_inplace_editable", + "args": { + "component": "format_topics", + "itemtype": "sectionname", + "itemid": str(section_id), + "value": name, + }, + } + ] + try: + resp = session.post( + ajax_url, + headers={"Content-Type": "application/json"}, + data=json.dumps(payload), + ) + resp.raise_for_status() + result = resp.json() + except requests.RequestException as exc: + raise MoodleSectionError( + f"Failed to communicate with Moodle to rename section: {exc}" + ) + + if result and isinstance(result, list) and result[0].get("error") is False: + return True + + error_details = "Unknown AJAX error" + if result and isinstance(result, list): + error_details = result[0].get("exception", {}).get("message", error_details) + raise MoodleSectionError(f"Error renaming section: {error_details}") + + +def ensure_section( + session: requests.Session, + base_url: str, + sesskey: str, + course_id: int, + *, + name: str, + token: Optional[str] = None, +) -> EnsureSectionResult: + """Ensure a section with the given name exists in the course. + + Looks up an existing section by ``name`` via + ``get_course_with_sections_and_modules``. If found, it is reused. Otherwise + a new section is created with ``create_section`` and then renamed to + ``name`` (see :func:`_rename_section` for the renaming mechanism and its + limitations). + + Args: + session: Authenticated requests session. + base_url: Base URL of the Moodle instance. + sesskey: Session key for AJAX/form calls. + course_id: ID of the course. + name: Section name used as the idempotency key. + token: Optional webservice token forwarded to the course lookup. + + Returns: + EnsureSectionResult: Typed result with ``status`` of ``"created"`` or + ``"reused"``. + """ + from py_moodle.course import get_course_with_sections_and_modules + from py_moodle.section import create_section + + course = get_course_with_sections_and_modules( + session, base_url, sesskey, course_id, token=token + ) + for section in course.get("sections", []): + if section.get("name") == name: + return EnsureSectionResult(status="reused", section=section) + + created = create_section(session, base_url, sesskey, course_id) + section_id = created.get("fields", {}).get("id") + _rename_section(session, base_url, sesskey, section_id, name) + return EnsureSectionResult( + status="created", section={"id": section_id, "name": name} + ) + + +__all__ = [ + "EnsureModuleResult", + "EnsureSectionResult", + "ensure_module", + "ensure_label", + "ensure_resource", + "ensure_folder", + "ensure_section", +] diff --git a/tests/unit/test_ensure_api.py b/tests/unit/test_ensure_api.py new file mode 100644 index 0000000..497b9cf --- /dev/null +++ b/tests/unit/test_ensure_api.py @@ -0,0 +1,386 @@ +"""Unit tests for the idempotent ensure-style content provisioning API. + +All Moodle interactions are fully mocked: no network access is performed. +The tests patch ``get_course_with_sections_and_modules`` (the lookup) and the +``add_*``/``create_section`` primitives (the mutations) so behaviour can be +asserted purely from Python. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from py_moodle import course as course_module +from py_moodle import ensure as ensure_pkg +from py_moodle.ensure import ( + EnsureModuleResult, + EnsureSectionResult, + ensure_folder, + ensure_label, + ensure_module, + ensure_resource, + ensure_section, +) + +SESSION = object() +BASE_URL = "https://moodle.example.test" +SESSKEY = "sesskey123" +COURSE_ID = 42 +SECTION_ID = 7 + + +def _patch_course(monkeypatch, course_data): + """Patch the course lookup to return ``course_data``.""" + monkeypatch.setattr( + course_module, + "get_course_with_sections_and_modules", + lambda *a, **k: course_data, + ) + + +# --------------------------------------------------------------------------- +# ensure_module() +# --------------------------------------------------------------------------- + + +def test_ensure_module_reuses_existing_without_calling_create_fn(monkeypatch): + """A module with matching (name, modname) is reused; create_fn is not run.""" + course_data = { + "sections": [ + {"modules": [{"id": 3, "name": "Other", "modname": "resource"}]}, + {"modules": [{"id": 101, "name": "Intro", "modname": "label"}]}, + ] + } + _patch_course(monkeypatch, course_data) + + create_fn = MagicMock(return_value=999) + result = ensure_module( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="Intro", + modname="label", + create_fn=create_fn, + ) + + assert isinstance(result, EnsureModuleResult) + assert result.status == "reused" + assert result.cmid == 101 + assert result.module == {"id": 101, "name": "Intro", "modname": "label"} + create_fn.assert_not_called() + + +def test_ensure_module_creates_when_absent(monkeypatch): + """When no module matches, create_fn is called exactly once.""" + _patch_course(monkeypatch, {"sections": []}) + + create_fn = MagicMock(return_value=555) + result = ensure_module( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="New", + modname="label", + create_fn=create_fn, + ) + + assert result.status == "created" + assert result.cmid == 555 + assert result.module is None + create_fn.assert_called_once_with() + + +def test_ensure_module_discriminates_by_modname(monkeypatch): + """A same-name module with a different modname is treated as absent.""" + course_data = { + "sections": [ + {"modules": [{"id": 9, "name": "Shared", "modname": "resource"}]}, + ] + } + _patch_course(monkeypatch, course_data) + + create_fn = MagicMock(return_value=12) + result = ensure_module( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="Shared", + modname="label", + create_fn=create_fn, + ) + + assert result.status == "created" + assert result.cmid == 12 + create_fn.assert_called_once_with() + + +# --------------------------------------------------------------------------- +# ensure_label / ensure_resource / ensure_folder delegation +# --------------------------------------------------------------------------- + + +def test_ensure_label_delegates_with_correct_modname_and_kwargs(monkeypatch): + """ensure_label forwards html/name/visible to add_label and binds modname.""" + _patch_course(monkeypatch, {"sections": []}) + + import py_moodle.label as label_module + + captured = {} + + def fake_add_label( + session, base_url, sesskey, course_id, section_id, html, name="x", visible=1 + ): + captured.update( + session=session, + base_url=base_url, + sesskey=sesskey, + course_id=course_id, + section_id=section_id, + html=html, + name=name, + visible=visible, + ) + return 777 + + monkeypatch.setattr(label_module, "add_label", fake_add_label) + + result = ensure_label( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="MyLabel", + html="

hi

", + visible=0, + ) + + assert result.status == "created" + assert result.cmid == 777 + assert captured["name"] == "MyLabel" + assert captured["html"] == "

hi

" + assert captured["visible"] == 0 + assert captured["course_id"] == COURSE_ID + assert captured["section_id"] == SECTION_ID + + +def test_ensure_label_reuses_existing_label(monkeypatch): + """A pre-existing label with the same name is reused without add_label.""" + course_data = { + "sections": [{"modules": [{"id": 3, "name": "MyLabel", "modname": "label"}]}] + } + _patch_course(monkeypatch, course_data) + + import py_moodle.label as label_module + + add_label_spy = MagicMock() + monkeypatch.setattr(label_module, "add_label", add_label_spy) + + result = ensure_label( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="MyLabel", + html="

hi

", + ) + + assert result.status == "reused" + assert result.cmid == 3 + add_label_spy.assert_not_called() + + +def test_ensure_resource_delegates_with_correct_modname_and_kwargs(monkeypatch): + """ensure_resource forwards name/file_path/intro/visible to add_resource.""" + _patch_course(monkeypatch, {"sections": []}) + + import py_moodle.resource as resource_module + + captured = {} + + def fake_add_resource( + session, + base_url, + sesskey, + course_id, + section_id, + name, + file_path, + intro="", + visible=1, + progress_callback=None, + ): + captured.update(name=name, file_path=file_path, intro=intro, visible=visible) + return 888 + + monkeypatch.setattr(resource_module, "add_resource", fake_add_resource) + + result = ensure_resource( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="Notes", + file_path="/tmp/a.pdf", + intro="hi", + visible=1, + ) + + assert result.status == "created" + assert result.cmid == 888 + assert captured["name"] == "Notes" + assert captured["file_path"] == "/tmp/a.pdf" + assert captured["intro"] == "hi" + + +def test_ensure_folder_delegates_with_correct_modname_and_kwargs(monkeypatch): + """ensure_folder forwards name/files_itemid/intro_html/visible to add_folder.""" + _patch_course(monkeypatch, {"sections": []}) + + import py_moodle.folder as folder_module + + captured = {} + + def fake_add_folder( + session, + base_url, + sesskey, + course_id, + section_id, + name, + files_itemid, + intro_html="", + visible=1, + ): + captured.update( + name=name, + files_itemid=files_itemid, + intro_html=intro_html, + visible=visible, + ) + return 999 + + monkeypatch.setattr(folder_module, "add_folder", fake_add_folder) + + result = ensure_folder( + SESSION, + BASE_URL, + SESSKEY, + COURSE_ID, + SECTION_ID, + name="Docs", + files_itemid=123, + intro_html="desc", + visible=1, + ) + + assert result.status == "created" + assert result.cmid == 999 + assert captured["name"] == "Docs" + assert captured["files_itemid"] == 123 + assert captured["intro_html"] == "desc" + + +# --------------------------------------------------------------------------- +# ensure_section() +# --------------------------------------------------------------------------- + + +def test_ensure_section_reuses_same_named_section(monkeypatch): + """A section whose name already matches is reused, no creation/rename.""" + course_data = {"sections": [{"id": 5, "name": "Week 1", "modules": []}]} + _patch_course(monkeypatch, course_data) + + import py_moodle.section as section_module + + create_spy = MagicMock() + monkeypatch.setattr(section_module, "create_section", create_spy) + rename_spy = MagicMock() + monkeypatch.setattr(ensure_pkg, "_rename_section", rename_spy) + + result = ensure_section(SESSION, BASE_URL, SESSKEY, COURSE_ID, name="Week 1") + + assert isinstance(result, EnsureSectionResult) + assert result.status == "reused" + assert result.section == {"id": 5, "name": "Week 1", "modules": []} + create_spy.assert_not_called() + rename_spy.assert_not_called() + + +def test_ensure_section_creates_and_renames_when_absent(monkeypatch): + """When no section matches, a section is created and then renamed.""" + _patch_course(monkeypatch, {"sections": [{"id": 1, "name": "General"}]}) + + import py_moodle.section as section_module + + created_event = {"name": "section", "fields": {"id": 77, "number": 3}} + create_spy = MagicMock(return_value=created_event) + monkeypatch.setattr(section_module, "create_section", create_spy) + rename_spy = MagicMock() + monkeypatch.setattr(ensure_pkg, "_rename_section", rename_spy) + + result = ensure_section(SESSION, BASE_URL, SESSKEY, COURSE_ID, name="New Week") + + assert result.status == "created" + assert result.section == {"id": 77, "name": "New Week"} + create_spy.assert_called_once() + rename_spy.assert_called_once_with(SESSION, BASE_URL, SESSKEY, 77, "New Week") + + +# --------------------------------------------------------------------------- +# create_or_update_course() +# --------------------------------------------------------------------------- + + +def test_create_or_update_course_delegates_with_update_true(monkeypatch): + """create_or_update_course calls ensure_course with update=True.""" + captured = {} + + def fake_ensure_course( + session, + base_url, + sesskey, + *, + shortname, + fullname, + category_id, + token=None, + update=False, + **create_kwargs, + ): + captured.update( + shortname=shortname, + fullname=fullname, + category_id=category_id, + token=token, + update=update, + create_kwargs=create_kwargs, + ) + return course_module.EnsureCourseResult(status="updated", course={"id": 1}) + + monkeypatch.setattr(course_module, "ensure_course", fake_ensure_course) + + result = course_module.create_or_update_course( + SESSION, + BASE_URL, + SESSKEY, + shortname="CS101", + fullname="Intro CS", + category_id=2, + numsections=6, + ) + + assert result.status == "updated" + assert captured["update"] is True + assert captured["shortname"] == "CS101" + assert captured["fullname"] == "Intro CS" + assert captured["category_id"] == 2 + assert captured["create_kwargs"] == {"numsections": 6}