diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1beca2..1295b5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,25 @@ jobs: - name: Check the generated sync tree is up to date run: python unasync_build.py --check + # The unit suite lives inside the package directory, so a lost + # exclusion pattern silently republishes it. Assert the built wheel, + # not just the config that is supposed to produce it. + - name: Build the wheel and assert it ships no tests + run: | + python -m build --wheel --outdir dist_check . + python - <<'PY' + import glob, sys, zipfile + + wheel = sorted(glob.glob("dist_check/*.whl"))[-1] + names = zipfile.ZipFile(wheel).namelist() + shipped = [n for n in names if "/tests/" in n or n.endswith("conftest.py")] + if shipped: + sys.exit("wheel ships test files:\n " + "\n ".join(shipped)) + if "glpi_python_client/testing/utils.py" not in names: + sys.exit("wheel is missing the documented testing helpers") + print(f"wheel is clean: {len(names)} entries, no tests") + PY + - name: Build documentation run: python -m sphinx -W --keep-going -b html docs docs/_build/html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d28153..2031e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **The unit test suite was published inside the wheel and the sdist.** Both + artefacts carried 56 test modules, so every install shipped the project's + own tests into the consuming environment. `[tool.hatch.build] exclude` now + drops `tests/` and `conftest.py`, taking the wheel from 180 entries to 126 + and the sdist from 204 to 150. Nothing a downstream consumer imports was + removed: `glpi_python_client.testing` — the documented factories, fixtures + and fake responses — still ships in full, and the package now imports + cleanly in an environment with no `pytest` installed. + - **A `Major` priority ticket made the whole search fail.** GLPI's priority scale has six levels; the published contract advertises five, and `GlpiPriority` followed the contract. Since `GetTicket.priority` is typed diff --git a/docs/development.md b/docs/development.md index dd14b68..c4346c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,9 @@ python -m pytest ticket descriptions, followups, tasks, and solutions. - `glpi_python_client.testing` exposes `make_client` and `make_async_client` factories that produce in-memory clients with no - real HTTP plumbing for downstream test suites. + real HTTP plumbing for downstream test suites, plus the shared + `DEFAULT_CLIENT_CONFIG` and the fake response classes. Its `tests/` + subpackage holds this repository's own cross-cutting suites. - `docs` contains the Read the Docs/Sphinx documentation source. - `skills` contains contributor-facing Agent Skills for repository workflows. The source distribution includes them for source consumers @@ -111,20 +113,33 @@ python -m pytest pagination logic in the focused `glpi_python_client._async.clients.commons` helper module named for that responsibility. -4. Add unit tests under `glpi_python_client/tests/**` for payload - serialization, response parsing, and client behaviour. They exercise - the generated sync client, which shares its statements with the async - one. - - Two suites cover what that cannot reach. - `glpi_python_client/tests/test_unasync_codegen.py` holds the +4. Add unit tests in the `tests/` package beside the module you changed — + `_async/clients/api/assistance/tests/test_ticket.py` for a change to + `_async/clients/api/assistance/_ticket.py`. Write them as `async def`, + then run `python unasync_build.py` and commit both trees. The + generated twins exercise the sync client, so one source covers both + surfaces. + + Import the client factory from `glpi_python_client._async._testing`, + not from `glpi_python_client.testing`: the former is generated, so it + returns an `AsyncGlpiClient` in the source and a `GlpiClient` in the + twin. The latter is published API and always returns what its name + says. + + Any stub replacing an awaited internal must be a named `async def`, not + a `lambda` — unasync cannot rewrite a lambda into a coroutine function. + + Two suites cover what colocated tests cannot reach. + `glpi_python_client/testing/tests/test_unasync_codegen.py` holds the invariants the CI diff gate is blind to — a token collision is deterministic, so regeneration reproduces it and the diff stays clean. - `glpi_python_client/tests/test_async_surface.py` runs the async client - for real: that a method actually awaits, that contending tasks do not - deadlock on the auth lock, and that `gather` genuinely overlaps its - arguments. Add to it whenever a change is only observable once - `async`/`await` are real. + `glpi_python_client/_async/tests/test_concurrency.py` and its `_sync` + twin cover what codegen cannot: that a method actually awaits, that + contending tasks do not deadlock on the auth lock, that `gather` + genuinely overlaps its arguments on the async side, and that the sync + twin's own lock excludes and its sequential `gather` preserves order. + Add to it whenever a change is only observable once `async`/`await` + are real. 5. Document the new workflow in `docs/user_guide.rst` or the README. Keep organization-specific defaults outside the package core. diff --git a/glpi_python_client/_async/_testing.py b/glpi_python_client/_async/_testing.py new file mode 100644 index 0000000..20097db --- /dev/null +++ b/glpi_python_client/_async/_testing.py @@ -0,0 +1,146 @@ +"""In-memory client factory and transport stubs for this tree's unit tests. + +One of a generated pair: the module is written once and +``unasync_build.py`` transforms it into its twin in the other tree. +``AsyncGlpiClient`` is already a substitution key, so each copy returns +the client of the tree it lands in while the factory stays spelled +``make_client`` on both sides -- call sites read identically wherever +they are. The same holds for the two recorder classes: each replaces a +client's four transport helpers with stubs that capture the call and hand +back a fixed response, so a mixin test can assert the endpoint, verb and +serialised body without any HTTP plumbing. + +It lives beside the tree rather than in ``glpi_python_client.testing`` +because that module is a published downstream helper whose factories must +keep their current names; this one has to change its return type when the +tree is transformed. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import AsyncGlpiClient +from glpi_python_client.testing.utils import DEFAULT_CLIENT_CONFIG, FakeResponse + +__all__ = ["FailingTransportRecorder", "TransportRecorder", "make_client"] + + +def make_client(**overrides: object) -> AsyncGlpiClient: + """Return a test client with no real HTTP plumbing. + + Any constructor keyword can be overridden while the rest of the shared + base configuration is reused. + """ + + config = dict(DEFAULT_CLIENT_CONFIG) + config.update(overrides) + return AsyncGlpiClient(**config) # type: ignore[arg-type] + + +class TransportRecorder: + """Capture the four transport calls and answer with fixed responses. + + Installed over the client's request helpers so a mixin test can assert + the endpoint, verb and serialised body without any HTTP plumbing. + """ + + def __init__( + self, + *, + get_payload: Any = None, + get_status: int = 200, + get_content: bytes | None = None, + post_payload: Any = None, + post_status: int = 201, + patch_status: int = 204, + delete_status: int = 204, + ) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + self._get_status = get_status + self._get_content = get_content + self._post_payload = post_payload if post_payload is not None else {"id": 999} + self._post_status = post_status + self._patch_status = patch_status + self._delete_status = delete_status + + def install(self, client: AsyncGlpiClient) -> None: + """Replace the four transport helpers with capturing stubs.""" + + async def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "endpoint": endpoint, + "params": params, + "skip_entity": skip_entity, + } + ) + return FakeResponse( + status_code=self._get_status, + payload=self._get_payload, + content=self._get_content, + ) + + async def _post( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "POST", + "endpoint": endpoint, + "json": json_body, + "skip_entity": skip_entity, + } + ) + return FakeResponse( + status_code=self._post_status, payload=self._post_payload + ) + + async def _patch( + endpoint: str, json_body: dict[str, Any] | None = None + ) -> FakeResponse: + self.calls.append( + {"method": "PATCH", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=self._patch_status, payload={}) + + async def _delete( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "DELETE", + "endpoint": endpoint, + "json": json_body, + "skip_entity": skip_entity, + } + ) + return FakeResponse(status_code=self._delete_status, payload={}) + + client._get_request = _get # type: ignore[method-assign, assignment] + client._post_request = _post # type: ignore[method-assign, assignment] + client._update_request = _patch # type: ignore[method-assign, assignment] + client._delete_request = _delete # type: ignore[method-assign, assignment] + + +class FailingTransportRecorder(TransportRecorder): + """Answer every verb with one fixed non-success status.""" + + def __init__(self, status: int) -> None: + super().__init__( + get_status=status, + get_payload={"err": "x"}, + post_status=status, + patch_status=status, + delete_status=status, + ) diff --git a/glpi_python_client/_async/auth/auth.py b/glpi_python_client/_async/auth/auth.py index 2ae6840..9386863 100644 --- a/glpi_python_client/_async/auth/auth.py +++ b/glpi_python_client/_async/auth/auth.py @@ -108,8 +108,9 @@ class GLPITokenManager: password : str | None, optional Password for the password grant flow. Provide it together with ``username``. - session : httpx.AsyncClient | None, optional - Existing HTTP client to reuse. + session : httpx.Client | httpx.AsyncClient | None, optional + Existing HTTP client to reuse. Each surface takes the httpx client + that matches it; the signature names the one in force here. auth_token_refresh : int | None, optional Maximum token age in seconds before a refresh is attempted. ``None`` disables interval-based refreshes. diff --git a/glpi_python_client/_async/auth/tests/__init__.py b/glpi_python_client/_async/auth/tests/__init__.py new file mode 100644 index 0000000..e1ca9b8 --- /dev/null +++ b/glpi_python_client/_async/auth/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for OAuth token management and the legacy v1 session.""" diff --git a/glpi_python_client/_async/auth/tests/test_auth.py b/glpi_python_client/_async/auth/tests/test_auth.py new file mode 100644 index 0000000..a19f820 --- /dev/null +++ b/glpi_python_client/_async/auth/tests/test_auth.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import cast + +import httpx +import pytest +from tenacity import wait_fixed + +from glpi_python_client import ( + GlpiAuthError, + GlpiServerError, + GlpiTransportError, + GlpiValidationError, +) +from glpi_python_client._async.auth.auth import GLPITokenManager +from glpi_python_client.testing.utils import FakeResponse, TokenResponse + + +class _FakeSession: + def __init__(self, response: FakeResponse | None = None): + self.response = response or TokenResponse() + self.calls: list[dict[str, object]] = [] + + async def post( + self, + url: str, + data: dict[str, str], + timeout: int, + ) -> FakeResponse: + self.calls.append({"url": url, "data": data, "timeout": timeout}) + return self.response + + +async def test_token_manager_uses_password_grant_with_user_credentials_only() -> None: + session = _FakeSession() + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + username="api-user", + password="api-password", + session=cast(httpx.AsyncClient, session), + ) + + await auth._acquire_token() + + assert session.calls[0]["data"] == { + "grant_type": "password", + "username": "api-user", + "password": "api-password", + "scope": "api", + } + assert auth.access_token == "token" + + +async def test_token_manager_uses_client_credentials_grant() -> None: + session = _FakeSession(response=TokenResponse(status_code=201)) + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + session=cast(httpx.AsyncClient, session), + ) + + await auth._acquire_token() + + assert session.calls[0]["data"] == { + "grant_type": "client_credentials", + "client_id": "client-id", + "client_secret": "client-secret", + "scope": "api", + } + assert auth.access_token == "token" + + +async def test_token_manager_preserves_raw_credential_text() -> None: + session = _FakeSession(response=TokenResponse(status_code=201)) + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id=" client-id ", + client_secret=" client-secret ", + session=cast(httpx.AsyncClient, session), + ) + + await auth._acquire_token() + + assert session.calls[0]["data"] == { + "grant_type": "client_credentials", + "client_id": " client-id ", + "client_secret": " client-secret ", + "scope": "api", + } + + +async def test_token_manager_uses_password_grant_with_both_credential_sets() -> None: + session = _FakeSession() + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + username="api-user", + password="api-password", + session=cast(httpx.AsyncClient, session), + ) + + await auth._acquire_token() + + assert session.calls[0]["data"] == { + "grant_type": "password", + "client_id": "client-id", + "client_secret": "client-secret", + "username": "api-user", + "password": "api-password", + "scope": "api", + } + + +async def test_token_manager_refreshes_when_configured_interval_elapses() -> None: + session = _FakeSession() + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + session=cast(httpx.AsyncClient, session), + auth_token_refresh=60, + ) + auth.access_token = "old-token" + auth.refresh_token = "refresh-token" + auth.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(seconds=61) + auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=1) + + await auth.ensure_token() + + assert auth.auth_token_refresh == 60 + assert session.calls[0]["data"] == { + "grant_type": "refresh_token", + "refresh_token": "refresh-token", + "client_id": "client-id", + "client_secret": "client-secret", + } + assert auth.access_token == "token" + + +@pytest.mark.parametrize("refresh_interval", [0, -1]) +def test_token_manager_rejects_non_positive_refresh_interval( + refresh_interval: int, +) -> None: + """``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="auth_token_refresh") as excinfo: + GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + auth_token_refresh=refresh_interval, + ) + assert isinstance(excinfo.value, ValueError) + + +def test_token_manager_logout_clears_cached_tokens() -> None: + auth = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + ) + auth.access_token = "access-token" + auth.refresh_token = "refresh-token" + auth.token_updated_at = datetime.now(tz=timezone.utc) + auth.token_expires_at = auth.token_updated_at + timedelta(hours=1) + + auth.logout() + + assert cast(str | None, auth.access_token) is None + assert cast(str | None, auth.refresh_token) is None + assert cast(datetime | None, auth.token_updated_at) is None + assert cast(datetime | None, auth.token_expires_at) is None + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "either client_id/client_secret, username/password, or both"), + ({"client_id": "client-id"}, "both client_id and client_secret"), + ({"client_secret": "client-secret"}, "both client_id and client_secret"), + ({"username": "api-user"}, "both username and password"), + ({"password": "api-password"}, "both username and password"), + ], +) +def test_token_manager_rejects_incomplete_credentials( + kwargs: dict[str, str], + message: str, +) -> None: + """``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match=message) as excinfo: + GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id=kwargs.get("client_id"), + client_secret=kwargs.get("client_secret"), + username=kwargs.get("username"), + password=kwargs.get("password"), + ) + assert isinstance(excinfo.value, ValueError) + + +async def test_oauth_401_raises_glpi_auth_error() -> None: + """A rejected credential surfaces as ``GlpiAuthError``, not a bare ValueError.""" + + session = _FakeSession( + response=TokenResponse(status_code=401, payload={"error": "invalid_client"}) + ) + manager = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="wrong", + session=cast(httpx.AsyncClient, session), + ) + with pytest.raises(GlpiAuthError) as excinfo: + await manager.ensure_token() + + assert excinfo.value.status_code == 401 + assert isinstance(excinfo.value, ValueError) + + +async def test_oauth_401_is_not_retried() -> None: + """A 4xx from the token endpoint is final; retrying cannot help.""" + + session = _FakeSession( + response=TokenResponse(status_code=401, payload={"error": "invalid_client"}) + ) + manager = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="wrong", + session=cast(httpx.AsyncClient, session), + ) + with pytest.raises(GlpiAuthError): + await manager.ensure_token() + + assert len(session.calls) == 1 + + +async def test_oauth_5xx_raises_glpi_server_error_after_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 5xx from the token endpoint is retried, then reraised as-is.""" + + monkeypatch.setattr(GLPITokenManager._acquire_token.retry, "wait", wait_fixed(0)) + session = _FakeSession(response=TokenResponse(status_code=503, payload={})) + manager = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + session=cast(httpx.AsyncClient, session), + ) + with pytest.raises(GlpiServerError) as excinfo: + await manager.ensure_token() + + assert excinfo.value.status_code == 503 + assert len(session.calls) == 3 + + +def _make_refresh_ready_manager( + session: _FakeSession, +) -> GLPITokenManager: + """Return a manager primed so ``ensure_token`` reaches ``_refresh_access_token``. + + ``ensure_token`` only calls ``_refresh_access_token`` when an access + token is already set *and* it is expired (or the proactive interval + elapsed). ``_acquire_token`` is never reached this way, unlike a fresh + manager, whose ``ensure_token`` always takes the acquire path. + """ + + manager = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + session=cast(httpx.AsyncClient, session), + ) + manager.access_token = "stale-token" + manager.refresh_token = "refresh-token" + manager.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(hours=2) + manager.token_expires_at = datetime.now(tz=timezone.utc) - timedelta(seconds=1) + return manager + + +async def test_refresh_401_stays_final_with_one_nested_acquire_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 4xx during refresh is not retried by either decorator. + + ``_refresh_access_token`` does not raise directly on a non-2xx response: + it logs a warning and falls through to a *nested* ``_acquire_token()`` + call (auth.py:302-303), which carries its own, independent retry + decorator. So even a "not retried" 4xx costs two POSTs -- one for the + failed refresh, one for the nested acquire that raises + ``GlpiAuthError`` -- rather than exactly one. Neither decorator's + predicate matches ``GlpiAuthError``, so the count stops there instead + of multiplying further (contrast with the 5xx case below). + """ + + monkeypatch.setattr(GLPITokenManager._acquire_token.retry, "wait", wait_fixed(0)) + monkeypatch.setattr( + GLPITokenManager._refresh_access_token.retry, "wait", wait_fixed(0) + ) + session = _FakeSession( + response=TokenResponse(status_code=401, payload={"error": "invalid_grant"}) + ) + manager = _make_refresh_ready_manager(session) + + with pytest.raises(GlpiAuthError) as excinfo: + await manager.ensure_token() + + assert excinfo.value.status_code == 401 + # 1 refresh POST (logged, falls through) + 1 nested _acquire_token POST + # (raises GlpiAuthError immediately; not retried by either decorator). + assert len(session.calls) == 2 + + +async def test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A persistent 5xx during refresh costs 4 POSTs, not 12. + + ``_refresh_access_token`` falls through to a *nested* ``_acquire_token()`` + call on any non-2xx response instead of raising directly + (auth.py:327-332). That nested call is independently decorated with + ``stop_after_attempt(3)`` and retries ``GlpiServerError``. This method's + own decorator only matches ``httpx.HTTPError`` (a genuine + network fault on the refresh POST itself), not ``GlpiServerError``, so it + does not retry the fall-through a second time on top of the nested + call's own retries. + + A persistent 5xx therefore costs exactly 1 refresh POST + 3 nested + acquire POSTs = 4 POST calls -- not the 3 (this method's attempts) x 4 + = 12 that resulted from a previous predicate that also matched + ``GlpiServerError`` here, duplicating the nested retries. This test pins + the fixed count so a future change (e.g. plan 3's httpx swap) cannot + silently reintroduce the multiplication. + """ + + monkeypatch.setattr(GLPITokenManager._acquire_token.retry, "wait", wait_fixed(0)) + monkeypatch.setattr( + GLPITokenManager._refresh_access_token.retry, "wait", wait_fixed(0) + ) + session = _FakeSession(response=TokenResponse(status_code=503, payload={})) + manager = _make_refresh_ready_manager(session) + + with pytest.raises(GlpiServerError) as excinfo: + await manager.ensure_token() + + assert excinfo.value.status_code == 503 + assert len(session.calls) == 4 + + +async def test_acquire_token_network_error_is_retried_three_times( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A connection-level failure (no HTTP response at all) is retried. + + Mirrors ``test_network_errors_are_still_retried`` in + ``clients/commons/tests/test_retry_semantics.py`` for the transport, but + covers the OAuth token path, which previously had no equivalent test. + """ + + monkeypatch.setattr(GLPITokenManager._acquire_token.retry, "wait", wait_fixed(0)) + + class _FailingSession: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def post( + self, url: str, data: dict[str, str], timeout: int + ) -> FakeResponse: + self.calls.append({"url": url, "data": data, "timeout": timeout}) + raise httpx.ConnectError("network down") + + session = _FailingSession() + manager = GLPITokenManager( + token_url="https://glpi.example.test/api.php/token", + client_id="client-id", + client_secret="client-secret", + session=cast(httpx.AsyncClient, session), + ) + + with pytest.raises(GlpiTransportError): + await manager.ensure_token() + + assert len(session.calls) == 3 + + +async def test_refresh_network_error_is_retried_three_times( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A connection-level failure during refresh is retried by refresh's own decorator. + + Pins the one predicate member not yet covered by another test: + ``_refresh_access_token``'s network-error retry. The fall-through + ``GlpiServerError`` case is pinned by + ``test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts`` + above, and ``_acquire_token``'s network retry is pinned by + ``test_acquire_token_network_error_is_retried_three_times``. A + ``httpx.ConnectError`` raised by ``session.post`` is translated to + ``GlpiTransportError`` and propagates + *before* ``_refresh_access_token`` reaches its non-2xx fallthrough + branch (auth.py:327-332), so the nested ``_acquire_token`` call is + never reached here -- unlike the persistent-5xx case, this pins + refresh's network retry count at 3, not 12. If a future rewrite of the + retry predicate (e.g. the httpx transport swap) drops this to 1 + without remapping the equivalent network exception, this test catches + it with every other test still green. + """ + + monkeypatch.setattr( + GLPITokenManager._refresh_access_token.retry, "wait", wait_fixed(0) + ) + + class _FailingSession: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def post( + self, url: str, data: dict[str, str], timeout: int + ) -> FakeResponse: + self.calls.append({"url": url, "data": data, "timeout": timeout}) + raise httpx.ConnectError("network down") + + session = _FailingSession() + manager = _make_refresh_ready_manager(cast(_FakeSession, session)) + + with pytest.raises(GlpiTransportError): + await manager.ensure_token() + + assert len(session.calls) == 3 diff --git a/glpi_python_client/_async/auth/tests/test_v1_session.py b/glpi_python_client/_async/auth/tests/test_v1_session.py new file mode 100644 index 0000000..effda30 --- /dev/null +++ b/glpi_python_client/_async/auth/tests/test_v1_session.py @@ -0,0 +1,561 @@ +"""Unit tests for :class:`GLPIV1Session` covering the upload lifecycle.""" + +from __future__ import annotations + +import json as jsonlib +from typing import Any, cast + +import httpx +import pytest +from tenacity import wait_fixed + +from glpi_python_client import ( + GlpiProtocolError, + GlpiServerError, + GlpiTransportError, + GlpiValidationError, +) +from glpi_python_client._async.auth._v1_session import GLPIV1Session +from glpi_python_client.testing.utils import FakeResponse + +#: Every ``GLPIV1Session`` method carrying the shared network retry decorator. +_RETRIED_METHODS = ( + "_init_session", + "request_json", + "upload_document", +) + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """Drop the 3s fixed wait so retry tests stay instant. + + Each decorated method's own ``Retrying`` object is patched directly. + Patching ``tenacity.nap.time.sleep`` would work today but silently stops + working on the async path, so it is deliberately not used here. + """ + + for name in _RETRIED_METHODS: + monkeypatch.setattr(getattr(GLPIV1Session, name).retry, "wait", wait_fixed(0)) + + +class _FakeV1Http: + """In-memory HTTP client stand-in capturing every call.""" + + def __init__(self, responses: dict[str, list[FakeResponse]]) -> None: + self._responses = responses + self.calls: list[dict[str, Any]] = [] + self.closed = False + self.verify = True + + def _next(self, key: str) -> FakeResponse: + return self._responses[key].pop(0) + + async def request( + self, + method: str, + url: str, + headers: dict[str, str], + timeout: int = 30, + **kwargs: Any, + ) -> FakeResponse: + """Verb-agnostic entry point mirroring the real dispatch. + + ``GLPIV1Session`` routes authenticated calls through + ``session.request(method, ...)`` rather than a per-verb attribute, + because that is the one call shape every transport agrees on. + This dispatches back to the per-verb handlers so their recorded + call shapes stay identical. + """ + + verb = method.upper() + handler = { + "GET": self.get, + "POST": self.post, + "PUT": self.put, + "DELETE": self.delete, + }.get(verb) + if handler is None: + raise AssertionError(f"unexpected verb {verb!r} in fake v1 session") + return handler(url, headers=headers, timeout=timeout, **kwargs) + + def get( + self, + url: str, + headers: dict[str, str], + timeout: int, + **kwargs: Any, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "url": url, + "headers": headers, + "timeout": timeout, + **kwargs, + } + ) + if url.endswith("/initSession"): + return self._next("init") + if url.endswith("/killSession"): + return self._next("kill") + return self._next("json") + + def post( + self, + url: str, + headers: dict[str, str], + timeout: int, + files: list[Any] | None = None, + **kwargs: Any, + ) -> FakeResponse: + self.calls.append( + { + "method": "POST", + "url": url, + "headers": headers, + "files": files, + "timeout": timeout, + **kwargs, + } + ) + if files is not None: + return self._next("upload") + return self._next("json") + + def put( + self, + url: str, + headers: dict[str, str], + timeout: int, + **kwargs: Any, + ) -> FakeResponse: + self.calls.append( + { + "method": "PUT", + "url": url, + "headers": headers, + "timeout": timeout, + **kwargs, + } + ) + return self._next("json") + + def delete( + self, + url: str, + headers: dict[str, str], + timeout: int, + **kwargs: Any, + ) -> FakeResponse: + self.calls.append( + { + "method": "DELETE", + "url": url, + "headers": headers, + "timeout": timeout, + **kwargs, + } + ) + return self._next("json") + + async def aclose(self) -> None: + self.closed = True + + +def _make(http: _FakeV1Http) -> GLPIV1Session: + """Build a ``GLPIV1Session`` whose HTTP layer is ``http``.""" + + session = GLPIV1Session( + base_url="https://glpi.example.test/apirest.php/", + user_token="user-token", + app_token="app-token", + verify_ssl=True, + ) + session._http = cast(httpx.AsyncClient, http) # type: ignore[assignment] + return session + + +def test_v1_session_rejects_bad_refresh_interval() -> None: + """Constructor enforces a positive refresh interval. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError) as excinfo: + GLPIV1Session( + base_url="https://glpi.example.test/apirest.php", + user_token="u", + app_token="a", + session_refresh_interval_seconds=0, + ) + assert isinstance(excinfo.value, ValueError) + + +async def test_v1_upload_acquires_session_then_posts() -> None: + """The first upload triggers ``initSession`` then the multipart POST.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "upload": [FakeResponse(status_code=201, payload={"id": 7})], + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + + result = await session.upload_document( + "a.txt", + b"abc", + "text/plain", + document_name="A", + ticket_id=3, + entity_id=2, + ) + assert result == {"id": 7} + + init_call = http.calls[0] + assert init_call["url"].endswith("/initSession") + + upload_call = http.calls[1] + assert upload_call["url"].endswith("/Document") + manifest_part = upload_call["files"][0] + payload = jsonlib.loads(manifest_part[1][1]) + assert payload == { + "input": { + "name": "A", + "_filename": ["a.txt"], + "entities_id": 2, + "itemtype": "Ticket", + "items_id": 3, + "tickets_id": 3, + } + } + + +async def test_v1_upload_renews_session_on_401() -> None: + """An auth-failure response triggers one renew + retry path.""" + + http = _FakeV1Http( + responses={ + "init": [ + FakeResponse(status_code=200, payload={"session_token": "tk1"}), + FakeResponse(status_code=200, payload={"session_token": "tk2"}), + ], + "kill": [FakeResponse(status_code=200, payload={})], + "upload": [ + FakeResponse(status_code=401, payload={"err": "expired"}), + FakeResponse(status_code=200, payload={"id": 9}), + ], + } + ) + session = _make(http) + + result = await session.upload_document("a.txt", b"x", "text/plain") + assert result == {"id": 9} + methods = [c["method"] + " " + c["url"].rsplit("/", 1)[-1] for c in http.calls] + # init -> upload(401) -> kill -> init -> upload(200) + assert methods == [ + "GET initSession", + "POST Document", + "GET killSession", + "GET initSession", + "POST Document", + ] + + +async def test_v1_upload_raises_on_5xx_after_retries() -> None: + """5xx upload responses are retried 3x and surface as ``GlpiServerError``.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "upload": [FakeResponse(status_code=500, payload={"err": "boom"})] * 3, + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + with pytest.raises(GlpiServerError) as excinfo: + await session.upload_document("a.txt", b"x", "text/plain") + assert excinfo.value.status_code == 500 + # The retry predicate must retry GlpiServerError, not just RequestException: + # pin the attempt count so a predicate regression fails loudly instead of + # silently dropping to 1 attempt. + upload_calls = [c for c in http.calls if c["url"].endswith("/Document")] + assert len(upload_calls) == 3 + + +async def test_v1_upload_raises_on_4xx_without_retry() -> None: + """Non-5xx non-success upload responses raise ``ValueError`` without retry.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "upload": [FakeResponse(status_code=400, payload={"err": "bad"})], + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + with pytest.raises(ValueError, match="document upload failed"): + await session.upload_document("a.txt", b"x", "text/plain") + # A single attempt was performed (init + one upload). + upload_calls = [c for c in http.calls if c["url"].endswith("/Document")] + assert len(upload_calls) == 1 + + +async def test_v1_upload_raises_on_unexpected_payload() -> None: + """A non-mapping JSON payload raises ``GlpiProtocolError`` without retry. + + ``GlpiProtocolError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "upload": [FakeResponse(status_code=200, payload=["unexpected"])], + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + with pytest.raises(GlpiProtocolError, match="unexpected payload") as excinfo: + await session.upload_document("a.txt", b"x", "text/plain") + assert isinstance(excinfo.value, ValueError) + + +async def test_v1_init_raises_on_5xx_after_retries() -> None: + """5xx ``initSession`` responses are retried 3x and raise ``GlpiServerError``.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=500, payload={"err": "boom"})] * 3, + } + ) + session = _make(http) + with pytest.raises(GlpiServerError) as excinfo: + await session._init_session() + assert excinfo.value.status_code == 500 + init_calls = [c for c in http.calls if c["url"].endswith("/initSession")] + assert len(init_calls) == 3 + + +async def test_v1_init_raises_on_4xx_without_retry() -> None: + """Non-5xx ``initSession`` responses raise ``ValueError`` immediately.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=401, payload={"err": "denied"})], + } + ) + session = _make(http) + with pytest.raises(ValueError, match="initSession failed"): + await session._init_session() + + +async def test_v1_init_raises_when_token_missing() -> None: + """``initSession`` returning no token raises ``GlpiProtocolError`` without retry. + + ``GlpiProtocolError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + http = _FakeV1Http( + responses={"init": [FakeResponse(status_code=200, payload={})]}, + ) + session = _make(http) + with pytest.raises(GlpiProtocolError, match="no session_token") as excinfo: + await session._init_session() + assert isinstance(excinfo.value, ValueError) + + +async def test_v1_close_kills_session_and_closes_http() -> None: + """``close`` kills any active session and closes the HTTP layer.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + await session._init_session() + await session.close() + assert http.closed is True + kills = [c for c in http.calls if c["url"].endswith("/killSession")] + assert len(kills) == 1 + + +async def test_v1_close_tolerates_kill_failure() -> None: + """A kill-session failure is logged but does not propagate.""" + + class _BoomHttp(_FakeV1Http): + def get(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse: + if url.endswith("/killSession"): + raise httpx.RequestError("boom") + return super().get(url, headers, timeout) + + http = _BoomHttp( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + } + ) + session = _make(http) + await session._init_session() + await session.close() # must not raise + assert http.closed is True + + +async def test_request_json_sends_body_and_returns_parsed_payload() -> None: + """``request_json`` serialises the body and decodes the JSON response.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "json": [FakeResponse(status_code=200, payload={"ok": True})], + "kill": [FakeResponse(status_code=200, payload={})], + } + ) + session = _make(http) + result = await session.request_json( + "POST", + "PluginFieldsContainer", + json_body={"input": {"name": "x"}}, + ) + assert result == {"ok": True} + post_call = next(call for call in http.calls if call["method"] == "POST") + assert post_call["url"].endswith("/PluginFieldsContainer") + assert post_call["content"] == jsonlib.dumps({"input": {"name": "x"}}) + assert post_call["headers"]["Content-Type"] == "application/json" + + +async def test_request_json_supports_get_with_params() -> None: + """``request_json`` forwards query params on GET calls.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "json": [FakeResponse(status_code=200, payload=[{"id": 1}])], + } + ) + session = _make(http) + out = await session.request_json( + "GET", "PluginFieldsContainer", params={"range": "0-1"} + ) + assert out == [{"id": 1}] + get_call = next( + call for call in http.calls if call["url"].endswith("/PluginFieldsContainer") + ) + assert get_call["params"] == {"range": "0-1"} + + +async def test_request_json_returns_empty_dict_on_empty_body() -> None: + """An empty response body decodes as an empty dict instead of raising.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "json": [FakeResponse(status_code=204, payload={}, content=b"")], + } + ) + session = _make(http) + assert await session.request_json("DELETE", "Some/Resource/1") == {} + + +async def test_request_json_raises_on_4xx_without_retry() -> None: + """Non-5xx non-success statuses raise ``ValueError`` without retry.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "json": [FakeResponse(status_code=404, payload={"err": "missing"})], + } + ) + session = _make(http) + with pytest.raises(ValueError, match="failed"): + await session.request_json("GET", "PluginFieldsContainer") + + +async def test_request_json_retries_on_5xx() -> None: + """5xx responses are retried 3x and surface as ``GlpiServerError``.""" + + http = _FakeV1Http( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + "json": [FakeResponse(status_code=500, payload={"err": "boom"})] * 3, + } + ) + session = _make(http) + with pytest.raises(GlpiServerError) as excinfo: + await session.request_json("GET", "PluginFieldsContainer") + assert excinfo.value.status_code == 500 + json_calls = [c for c in http.calls if c["url"].endswith("/PluginFieldsContainer")] + assert len(json_calls) == 3 + + +async def test_request_json_retries_on_network_error() -> None: + """Network faults during ``request_json`` are retried 3x, not swallowed. + + Pins the ``GlpiTransportError`` member of the v1 retry predicate + (``_RETRY_ON_NETWORK_ERRORS`` in ``_v1_session.py``): the 5xx tests above + only exercise the ``GlpiServerError`` member. Without this test a future + edit that narrows the predicate to drop ``GlpiTransportError`` + would silently + drop v1 network retries from 3 attempts to 1 while every committed test + stayed green. + """ + + class _FlakyHttp(_FakeV1Http): + def get( + self, + url: str, + headers: dict[str, str], + timeout: int, + **kwargs: Any, + ) -> FakeResponse: + if url.endswith("/PluginFieldsContainer"): + self.calls.append( + { + "method": "GET", + "url": url, + "headers": headers, + "timeout": timeout, + **kwargs, + } + ) + raise httpx.ConnectError("network down") + return super().get(url, headers, timeout, **kwargs) + + http = _FlakyHttp( + responses={ + "init": [FakeResponse(status_code=200, payload={"session_token": "tk"})], + } + ) + session = _make(http) + with pytest.raises(GlpiTransportError): + await session.request_json("GET", "PluginFieldsContainer") + json_calls = [c for c in http.calls if c["url"].endswith("/PluginFieldsContainer")] + assert len(json_calls) == 3 + + +async def test_session_token_invalid_marker_triggers_renew() -> None: + """An ``ERROR_SESSION_TOKEN_INVALID`` body marker counts as an auth failure.""" + + body_text = "ERROR_SESSION_TOKEN_INVALID" + http = _FakeV1Http( + responses={ + "init": [ + FakeResponse(status_code=200, payload={"session_token": "tk1"}), + FakeResponse(status_code=200, payload={"session_token": "tk2"}), + ], + "kill": [FakeResponse(status_code=200, payload={})], + "upload": [ + FakeResponse(status_code=200, payload={"id": 1}, text=body_text), + FakeResponse(status_code=200, payload={"id": 2}), + ], + } + ) + session = _make(http) + + result = await session.upload_document("a.txt", b"x", "text/plain") + assert result == {"id": 2} diff --git a/glpi_python_client/_async/clients/api/administration/__init__.py b/glpi_python_client/_async/clients/api/administration/__init__.py index c05991d..7a5fa7f 100644 --- a/glpi_python_client/_async/clients/api/administration/__init__.py +++ b/glpi_python_client/_async/clients/api/administration/__init__.py @@ -1,8 +1,8 @@ """GLPI ``/Administration`` mixins for the GLPI client. -The submodules expose the user and entity mixins used by -:class:`glpi_python_client.GlpiClient` and -:class:`glpi_python_client.AsyncGlpiClient`. +The submodules expose the user and entity mixins used by this tree's +client -- :class:`glpi_python_client.AsyncGlpiClient` on the async +surface, :class:`glpi_python_client.GlpiClient` on the generated one. """ from __future__ import annotations diff --git a/glpi_python_client/_async/clients/api/administration/tests/__init__.py b/glpi_python_client/_async/clients/api/administration/tests/__init__.py new file mode 100644 index 0000000..fad2619 --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the user and entity endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/administration/tests/test_entity.py b/glpi_python_client/_async/clients/api/administration/tests/test_entity.py new file mode 100644 index 0000000..24b3f6f --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/tests/test_entity.py @@ -0,0 +1,195 @@ +"""Unit tests for the ``Administration/Entity`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI entities, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchEntity, PostEntity +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + + +async def test_search_entities_skips_entity_header(client: Any) -> None: + """``search_entities`` skips the GLPI-Entity header.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "root"}]) + rec.install(client) + entities = await client.search_entities("name==root", limit=None, start=0) + assert entities[0].id == 1 + assert rec.calls[0]["skip_entity"] is True + assert "limit" not in rec.calls[0]["params"] + + +async def test_get_entity_skips_entity_header(client: Any) -> None: + """``get_entity`` also bypasses the entity header.""" + + rec = TransportRecorder(get_payload={"id": 2, "name": "root"}) + rec.install(client) + entity = await client.get_entity(2) + assert entity.id == 2 + assert rec.calls[0]["endpoint"] == "Administration/Entity/2" + assert rec.calls[0]["skip_entity"] is True + + +async def test_update_entity_patch(client: Any) -> None: + """``update_entity`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_entity(2, PatchEntity(name="renamed")) + assert rec.calls[0]["endpoint"] == "Administration/Entity/2" + + +async def test_delete_entity_with_force(client: Any) -> None: + """``delete_entity(force=True)`` ships the force flag and skips entity.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_entity(2, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Entity/2" + assert call["json"] == {"force": True} + assert call["skip_entity"] is True + + +async def test_create_entity_id_returned(client: Any) -> None: + """``create_entity`` returns the newly created identifier.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + entity_id = await client.create_entity(PostEntity(name="root")) + assert entity_id == 42 + assert rec.calls[0]["endpoint"] == "Administration/Entity" + assert rec.calls[0]["skip_entity"] is True + + +async def test_create_entity_skips_entity_header(client: Any) -> None: + """Entity create requests bypass the GLPI-Entity header.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_entity(PostEntity(name="root")) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Entity" + assert call["skip_entity"] is True + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_entity(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_entity(1, PatchEntity(name="x")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_entity(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +# --------------------------------------------------------------------------- +# iter_search_entities +# --------------------------------------------------------------------------- + + +async def test_iter_search_entities_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "name": "root"}] + + client.search_entities = fake_search # type: ignore[method-assign] + batches = [b async for b in client.iter_search_entities("", batch_size=50)] + assert call_count == 1 + assert len(batches) == 1 + + +async def test_iter_search_entities_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first short entity batch.""" + + responses = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_entities = fake_search # type: ignore[method-assign] + batches = [batch async for batch in client.iter_search_entities("", batch_size=2)] + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_async/clients/api/administration/tests/test_user.py b/glpi_python_client/_async/clients/api/administration/tests/test_user.py new file mode 100644 index 0000000..b378e05 --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/tests/test_user.py @@ -0,0 +1,194 @@ +"""Unit tests for the ``Administration/User`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI users, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchUser, PostUser +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + + +async def test_search_users_forwards_skip_entity(client: Any) -> None: + """``search_users`` forwards the ``skip_entity`` flag.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "username": "alice"}]) + rec.install(client) + users = await client.search_users("username==alice", skip_entity=True) + assert len(users) == 1 + assert rec.calls[0]["skip_entity"] is True + assert rec.calls[0]["params"]["filter"] == "username==alice" + + +async def test_get_user_targets_user_endpoint(client: Any) -> None: + """``get_user`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 5, "username": "alice"}) + rec.install(client) + user = await client.get_user(5) + assert user.id == 5 + assert rec.calls[0]["endpoint"] == "Administration/User/5" + + +async def test_update_user_sends_patch(client: Any) -> None: + """``update_user`` issues PATCH against the user endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_user(5, PatchUser(firstname="Alice")) + assert rec.calls[0]["method"] == "PATCH" + assert rec.calls[0]["endpoint"] == "Administration/User/5" + + +async def test_create_user_serialises_post_body(client: Any) -> None: + """``create_user`` serialises the ``PostUser`` model into the POST body.""" + + rec = TransportRecorder() + rec.install(client) + user_id = await client.create_user(PostUser(username="alice")) + assert user_id == 999 + assert rec.calls == [ + { + "method": "POST", + "endpoint": "Administration/User", + "json": {"username": "alice"}, + "skip_entity": False, + } + ] + + +async def test_delete_user_supports_force_flag(client: Any) -> None: + """``delete_user`` forwards the ``force`` flag inside the JSON body.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_user(5, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Administration/User/5" + assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_user(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_user(1, PatchUser(firstname="x")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_user(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +# --------------------------------------------------------------------------- +# iter_search_users +# --------------------------------------------------------------------------- + + +async def test_iter_search_users_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "username": "alice"}] + + client.search_users = fake_search # type: ignore[method-assign] + batches = [ + b async for b in client.iter_search_users("username==alice", batch_size=50) + ] + assert call_count == 1 + assert len(batches) == 1 + + +async def test_iter_search_users_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first short user batch.""" + + responses = [ + [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}], + [{"id": 3, "username": "carol"}], + ] + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_users = fake_search # type: ignore[method-assign] + batches = [batch async for batch in client.iter_search_users("", batch_size=2)] + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_async/clients/api/assistance/tests/__init__.py b/glpi_python_client/_async/clients/api/assistance/tests/__init__.py new file mode 100644 index 0000000..3fba735 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ticket and ticket-team endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/assistance/tests/test_team.py b/glpi_python_client/_async/clients/api/assistance/tests/test_team.py new file mode 100644 index 0000000..ab3c8c9 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/tests/test_team.py @@ -0,0 +1,97 @@ +"""Unit tests for the ``Assistance/Ticket/TeamMember`` endpoint mixin. + +The tests cover listing, adding, and removing ticket team members, using +the shared transport recorders to stub the four transport helpers without +any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PostTeamMember +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +async def test_list_ticket_team_members_endpoint(client: Any) -> None: + """``list_ticket_team_members`` hits the team-member endpoint.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "type": "User", "role": "assigned"}]) + rec.install(client) + members = await client.list_ticket_team_members(7) + assert members[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/TeamMember" + + +async def test_add_ticket_team_member_targets_team_endpoint(client: Any) -> None: + """``add_ticket_team_member`` posts to the ticket team-member endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.add_ticket_team_member( + 11, PostTeamMember(type="User", id=42, role="assigned") + ) + + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/11/TeamMember" + assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} + + +async def test_remove_ticket_team_member_uses_delete(client: Any) -> None: + """``remove_ticket_team_member`` issues DELETE with the member body.""" + + rec = TransportRecorder() + rec.install(client) + await client.remove_ticket_team_member( + 7, PostTeamMember(type="User", id=42, role="assigned") + ) + + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Assistance/Ticket/7/TeamMember" + assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_ticket_team_members(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.remove_ticket_team_member( + 1, PostTeamMember(type="User", id=2, role="assigned") + ), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py b/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py new file mode 100644 index 0000000..989cdcd --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/tests/test_ticket.py @@ -0,0 +1,273 @@ +"""Unit tests for the ``Assistance/Ticket`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI tickets, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTicket, PostTicket +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Tickets +# --------------------------------------------------------------------------- + + +async def test_search_tickets_forwards_sort_and_fields(client: Any) -> None: + """Sort and field selection both flow into the GET query parameters.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "n", "content": "c"}]) + rec.install(client) + tickets = await client.search_tickets( + "status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name") + ) + + assert len(tickets) == 1 + assert rec.calls[0]["params"]["filter"] == "status==1" + assert rec.calls[0]["params"]["limit"] == 5 + assert rec.calls[0]["params"]["start"] == 10 + assert rec.calls[0]["params"]["sort"] == "date_mod desc" + assert rec.calls[0]["params"]["fields"] == "id,name" + + +async def test_search_tickets_uses_filter_query_param(client: Any) -> None: + """``search_tickets`` forwards the RSQL filter via the ``filter`` parameter.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "demo"}]) + rec.install(client) + tickets = await client.search_tickets(rsql_filter="status==1", limit=20) + assert len(tickets) == 1 + assert rec.calls[0]["method"] == "GET" + assert rec.calls[0]["endpoint"] == "Assistance/Ticket" + assert rec.calls[0]["params"]["filter"] == "status==1" + assert rec.calls[0]["params"]["limit"] == 20 + + +async def test_get_ticket_returns_validated_model(client: Any) -> None: + """Single ticket responses are validated through ``GetTicket``.""" + + rec = TransportRecorder( + get_payload={"id": 7, "name": "demo", "content": "
c
"} + ) + rec.install(client) + ticket = await client.get_ticket(7) + assert ticket.id == 7 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7" + + +async def test_create_ticket_serialises_enums(client: Any) -> None: + """``create_ticket`` serialises enum values as their numeric form.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_ticket(PostTicket(name="t", content="c
")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket" + assert call["json"]["name"] == "t" + + +async def test_update_ticket_sends_patch(client: Any) -> None: + """Update sends a PATCH with the partial body.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_ticket(7, PatchTicket(content="x
")) + call = rec.calls[0] + assert call["method"] == "PATCH" + assert call["endpoint"] == "Assistance/Ticket/7" + assert call["json"] == {"content": "x
"} + + +async def test_delete_ticket_omits_body_without_force(client: Any) -> None: + """``delete_ticket(force=None)`` omits the JSON body.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_ticket(7) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Assistance/Ticket/7" + assert call["json"] is None + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket(1, PatchTicket(content="x
")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +# --------------------------------------------------------------------------- +# iter_search_tickets +# --------------------------------------------------------------------------- + + +async def test_iter_search_tickets_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + pages: list[list[Any]] = [[{"id": 1, "name": "t1", "content": "c"}]] + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return pages[0] + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [b async for b in client.iter_search_tickets("status==1", batch_size=50)] + assert call_count == 1 + assert len(batches) == 1 + assert len(batches[0]) == 1 + + +async def test_iter_search_tickets_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first batch shorter than batch_size.""" + + ticket_a = {"id": 1, "name": "a", "content": "c"} + ticket_b = {"id": 2, "name": "b", "content": "c"} + ticket_c = {"id": 3, "name": "c", "content": "c"} + responses = [ + [ticket_a, ticket_b], # full page → continue + [ticket_c], # short page → last + ] + call_count = 0 + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [batch async for batch in client.iter_search_tickets("", batch_size=2)] + assert call_count == 2 + assert len(batches) == 2 + assert len(batches[0]) == 2 + assert len(batches[1]) == 1 + + +async def test_iter_search_tickets_forwards_pagination_and_query_options( + client: Any, +) -> None: + """Each page advances ``start`` and repeats the caller's query options. + + The sibling tests above count pages and stub the search away, so they + pass whatever the generator sends. This one records every call: it is + the only thing pinning the offset arithmetic and the per-page + forwarding of ``filter``, ``sort`` and ``fields``. + """ + + ticket = {"id": 1, "name": "a", "content": "c"} + responses = [[ticket, ticket], [ticket, ticket], [ticket]] + calls: list[dict[str, Any]] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + calls.append( + { + "rsql_filter": rsql_filter, + "limit": limit, + "start": start, + "sort": sort, + "fields": fields, + } + ) + return responses[len(calls) - 1] + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [ + batch + async for batch in client.iter_search_tickets( + "status==1", batch_size=2, sort="id:desc", fields=("id", "name") + ) + ] + + assert len(batches) == 3 + # One full page per step. A generator that failed to advance the offset + # would re-request page zero forever against a real server. + assert [call["start"] for call in calls] == [0, 2, 4] + assert [call["limit"] for call in calls] == [2, 2, 2] + # Every page repeats the caller's options. Forwarding them only on the + # first request would silently change the sort order and widen the + # field set from page two onwards. + assert all(call["rsql_filter"] == "status==1" for call in calls) + assert all(call["sort"] == "id:desc" for call in calls) + assert all(call["fields"] == ("id", "name") for call in calls) diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/__init__.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/__init__.py new file mode 100644 index 0000000..18a2565 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ticket timeline endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py new file mode 100644 index 0000000..5a0f465 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py @@ -0,0 +1,110 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Document`` endpoint mixin. + +The tests cover listing, fetching, linking, updating, and unlinking ticket +timeline documents, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTimelineDocument, PostTimelineDocument +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +async def test_list_get_update_unlink_timeline_documents(client: Any) -> None: + """All four timeline document helpers target the document endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "Document_Item", "item": {"id": 1, "filename": "report.txt"}}, + ] + ) + rec.install(client) + items = await client.list_ticket_timeline_documents(7) + assert items[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Document" + + rec._get_payload = {"id": 1, "filename": "report.txt"} # type: ignore[attr-defined] + doc = await client.get_ticket_timeline_document(7, 1) + assert doc.id == 1 + + await client.update_ticket_timeline_document(7, 1, PatchTimelineDocument()) + await client.unlink_ticket_timeline_document(7, 1, force=True) + + methods = [c["method"] for c in rec.calls] + assert methods == ["GET", "GET", "PATCH", "DELETE"] + + +async def test_link_ticket_timeline_document_targets_document_endpoint( + client: Any, +) -> None: + """``link_ticket_timeline_document`` targets the document timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.link_ticket_timeline_document(10, PostTimelineDocument()) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/10/Timeline/Document" + assert call["json"] == {} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_timeline_document(1, 2), + lambda c: c.list_ticket_timeline_documents(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_timeline_document(1, 2, PatchTimelineDocument()), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.unlink_ticket_timeline_document(1, 2, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_followup.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_followup.py new file mode 100644 index 0000000..7256cbe --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_followup.py @@ -0,0 +1,127 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Followup`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +followups, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchFollowup, PostFollowup +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +async def test_list_ticket_followups_unwraps_envelope(client: Any) -> None: + """Live envelope ``{"type":..,"item":..}`` entries are unwrapped.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "ITILFollowup", "item": {"id": 11, "content": "hi"}}, + {"id": 12, "content": "bye"}, + ] + ) + rec.install(client) + items = await client.list_ticket_followups(7) + assert [i.id for i in items] == [11, 12] + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" + + +async def test_get_ticket_followup_endpoint(client: Any) -> None: + """``get_ticket_followup`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 11, "content": "x"}) + rec.install(client) + followup = await client.get_ticket_followup(7, 11) + assert followup.id == 11 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" + + +async def test_update_ticket_followup_patch(client: Any) -> None: + """``update_ticket_followup`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_ticket_followup(7, 11, PatchFollowup(content="up
")) + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" + + +async def test_delete_ticket_followup_force(client: Any) -> None: + """``delete_ticket_followup(force=True)`` adds the body.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_ticket_followup(7, 11, force=True) + assert rec.calls[0]["json"] == {"force": True} + + +async def test_create_ticket_followup_targets_timeline_endpoint(client: Any) -> None: + """``create_ticket_followup`` posts to the ticket timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_ticket_followup(7, PostFollowup(content="hi
")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" + assert call["json"] == {"content": "hi
"} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_followup(1, 2), + lambda c: c.list_ticket_followups(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_followup(1, 2, PatchFollowup(content="x
")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_followup(1, 2, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_solution.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_solution.py new file mode 100644 index 0000000..3411517 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_solution.py @@ -0,0 +1,110 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Solution`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +solutions, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchSolution, PostSolution +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +async def test_list_get_update_delete_ticket_solutions(client: Any) -> None: + """All four solution helpers target the solution timeline endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "ITILSolution", "item": {"id": 1, "content": "x"}}, + ] + ) + rec.install(client) + sols = await client.list_ticket_solutions(7) + assert sols[0].id == 1 + + rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] + sol = await client.get_ticket_solution(7, 1) + assert sol.id == 1 + + await client.update_ticket_solution(7, 1, PatchSolution(content="up
")) + await client.delete_ticket_solution(7, 1, force=True) + + methods = [c["method"] for c in rec.calls] + assert methods == ["GET", "GET", "PATCH", "DELETE"] + endpoints = {c["endpoint"] for c in rec.calls if c["method"] != "GET"} | { + c["endpoint"] for c in rec.calls if c["method"] == "GET" + } + assert any("Solution" in e for e in endpoints) + + +async def test_create_ticket_solution_uses_solution_endpoint(client: Any) -> None: + """``create_ticket_solution`` targets the ticket solution endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_ticket_solution(9, PostSolution(content="ok")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/9/Timeline/Solution" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_solution(1, 2), + lambda c: c.list_ticket_solutions(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_solution(1, 2, PatchSolution(content="x
")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_solution(1, 2, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_task.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_task.py new file mode 100644 index 0000000..e3aed11 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_task.py @@ -0,0 +1,113 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Task`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +tasks, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTicketTask, PostTicketTask +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +async def test_list_get_update_delete_ticket_tasks(client: Any) -> None: + """All four task helpers target the task timeline endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "TicketTask", "item": {"id": 1, "content": "x"}}, + ] + ) + rec.install(client) + tasks = await client.list_ticket_tasks(7) + assert tasks[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Task" + + rec.calls.clear() + rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] + task = await client.get_ticket_task(7, 1) + assert task.id == 1 + + await client.update_ticket_task(7, 1, PatchTicketTask(content="up
")) + await client.delete_ticket_task(7, 1, force=True) + + endpoints = [c["endpoint"] for c in rec.calls] + assert endpoints == [ + "Assistance/Ticket/7/Timeline/Task/1", + "Assistance/Ticket/7/Timeline/Task/1", + "Assistance/Ticket/7/Timeline/Task/1", + ] + + +async def test_create_ticket_task_uses_task_endpoint(client: Any) -> None: + """``create_ticket_task`` targets the ticket task timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_ticket_task(8, PostTicketTask(content="task", duration=120)) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/8/Timeline/Task" + assert call["json"] == {"content": "task
", "duration": 120} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_task(1, 2), + lambda c: c.list_ticket_tasks(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_task(1, 2, PatchTicketTask(content="x
")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_task(1, 2, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/dropdowns/tests/__init__.py b/glpi_python_client/_async/clients/api/dropdowns/tests/__init__.py new file mode 100644 index 0000000..009b736 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the dropdown endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py b/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py new file mode 100644 index 0000000..bff93c9 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/tests/test_location.py @@ -0,0 +1,128 @@ +"""Unit tests for the ``Dropdowns/Location`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +locations, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchLocation, PostLocation +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Locations +# --------------------------------------------------------------------------- + + +async def test_search_locations_passes_filter(client: Any) -> None: + """``search_locations`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Paris"}]) + rec.install(client) + locations = await client.search_locations("name==Paris") + assert locations[0].id == 1 + assert rec.calls[0]["endpoint"] == "Dropdowns/Location" + assert rec.calls[0]["params"]["filter"] == "name==Paris" + + +async def test_get_location_endpoint(client: Any) -> None: + """``get_location`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Paris"}) + rec.install(client) + loc = await client.get_location(9) + assert loc.id == 9 + assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" + + +async def test_update_location(client: Any) -> None: + """``update_location`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_location(9, PatchLocation(name="Paris HQ")) + assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" + + +async def test_delete_location_with_force(client: Any) -> None: + """``delete_location(force=True)`` ships the force flag in the body.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_location(9, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Dropdowns/Location/9" + assert call["json"] == {"force": True} + + +async def test_create_location_targets_dropdown_endpoint(client: Any) -> None: + """``create_location`` posts to the dropdown endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.create_location(PostLocation(name="Paris")) + call = rec.calls[0] + assert call["endpoint"] == "Dropdowns/Location" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_location(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_location(1, PatchLocation(name="x")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_location(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/__init__.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/__init__.py new file mode 100644 index 0000000..e59b662 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the knowledge base endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py new file mode 100644 index 0000000..4a22076 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_article.py @@ -0,0 +1,347 @@ +"""Recorder-based unit tests for ``KBArticleMixin``.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import ( + GlpiValidationError, + IdNameRef, + PatchKBArticle, + PostKBArticle, +) +from glpi_python_client._async._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse + + +class _FakeV1: + """Stand-in for GLPIV1Session recording ``request_json`` calls.""" + + def __init__(self, *, error: Exception | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._error = error + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "json_body": json_body, + "failure_message": failure_message, + } + ) + if self._error is not None: + raise self._error + return [{"1": True, "message": ""}] + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +class _Recorder: + """Transport recorder returning canned FakeResponse objects.""" + + def __init__(self, *, get_payload: Any = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + + def install(self, client: Any) -> None: + async def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "endpoint": endpoint, + "params": params, + "skip_entity": skip_entity, + } + ) + return FakeResponse(status_code=200, payload=self._get_payload) + + async def _post( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "POST", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=201, payload={"id": 88}) + + async def _patch( + endpoint: str, json_body: dict[str, Any] | None = None + ) -> FakeResponse: + self.calls.append( + {"method": "PATCH", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + async def _delete( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "DELETE", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + client._get_request = _get # type: ignore[method-assign] + client._post_request = _post # type: ignore[method-assign] + client._update_request = _patch # type: ignore[method-assign] + client._delete_request = _delete # type: ignore[method-assign] + + +async def test_search_kb_articles_forwards_params(client: Any) -> None: + rec = _Recorder(get_payload=[{"id": 1, "name": "Reset", "content": "c
"}]) + rec.install(client) + result = await client.search_kb_articles( + "is_faq==1", limit=3, start=1, sort="date_mod desc", language="en_GB" + ) + assert result[0].id == 1 + call = rec.calls[0] + assert call["endpoint"] == "Knowledgebase/Article" + assert call["params"]["filter"] == "is_faq==1" + assert call["params"]["limit"] == 3 + assert call["params"]["start"] == 1 + assert call["params"]["sort"] == "date_mod desc" + assert call["params"]["language"] == "en_GB" + + +async def test_get_kb_article_targets_per_id_endpoint(client: Any) -> None: + rec = _Recorder(get_payload={"id": 5, "name": "Reset", "content": "c
"}) + rec.install(client) + article = await client.get_kb_article(5) + assert article.id == 5 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5" + + +async def test_create_kb_article_renders_markdown_and_returns_id(client: Any) -> None: + rec = _Recorder() + rec.install(client) + new_id = await client.create_kb_article( + PostKBArticle(name="How to", content="Run **passwd**") + ) + assert new_id == 88 + call = rec.calls[0] + assert call["method"] == "POST" + assert call["endpoint"] == "Knowledgebase/Article" + assert call["json"]["name"] == "How to" + assert "passwd" in call["json"]["content"] + + +async def test_update_kb_article_sends_patch(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.update_kb_article(5, PatchKBArticle(is_pinned=True)) + call = rec.calls[0] + assert call["method"] == "PATCH" + assert call["endpoint"] == "Knowledgebase/Article/5" + assert call["json"] == {"is_pinned": True} + + +async def test_delete_kb_article_with_force(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.delete_kb_article(5, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Knowledgebase/Article/5" + assert call["json"] == {"force": True} + + +async def test_set_kb_article_categories_writes_via_v1(client: Any) -> None: + fake = _FakeV1() + client._v1 = fake # type: ignore[assignment] + await client.set_kb_article_categories(31, [14, 15]) + call = fake.calls[0] + assert call["method"] == "PUT" + assert call["path"] == "KnowbaseItem/31" + assert call["json_body"] == {"input": {"_categories": [14, 15]}} + + +async def test_set_kb_article_categories_empty_clears(client: Any) -> None: + fake = _FakeV1() + client._v1 = fake # type: ignore[assignment] + await client.set_kb_article_categories(31, []) + assert fake.calls[0]["json_body"] == {"input": {"_categories": []}} + + +async def test_set_kb_article_categories_requires_v1(client: Any) -> None: + assert client._v1 is None + with pytest.raises(RuntimeError): + await client.set_kb_article_categories(31, [14]) + + +async def test_create_kb_article_applies_categories_via_v1(client: Any) -> None: + rec = _Recorder() + rec.install(client) + fake = _FakeV1() + client._v1 = fake # type: ignore[assignment] + new_id = await client.create_kb_article( + PostKBArticle(name="P", content="c", categories=[IdNameRef(id=14)]) + ) + assert new_id == 88 + assert rec.calls[0]["method"] == "POST" + assert fake.calls[0]["path"] == "KnowbaseItem/88" + assert fake.calls[0]["json_body"] == {"input": {"_categories": [14]}} + + +async def test_create_kb_article_without_categories_skips_v1(client: Any) -> None: + rec = _Recorder() + rec.install(client) + assert client._v1 is None # no v1 configured + new_id = await client.create_kb_article(PostKBArticle(name="P", content="c")) + assert new_id == 88 # no RuntimeError despite missing v1 + + +async def test_create_kb_article_category_failure_raises_without_rollback( + client: Any, +) -> None: + rec = _Recorder() + rec.install(client) + client._v1 = _FakeV1(error=ValueError("boom")) # type: ignore[assignment] + with pytest.raises(RuntimeError, match="88") as excinfo: + await client.create_kb_article( + PostKBArticle(name="P", content="c", categories=[IdNameRef(id=14)]) + ) + # The article is NOT rolled back; the failure just raises, naming the id + # and chaining the original error so the partial state is recoverable. + assert not any(c["method"] == "DELETE" for c in rec.calls) + assert isinstance(excinfo.value.__cause__, ValueError) + assert "boom" in str(excinfo.value.__cause__) + + +async def test_create_kb_article_ref_without_id_raises(client: Any) -> None: + """``create_kb_article`` wraps the failure in ``RuntimeError`` (kept bare + by design), chaining the underlying ``GlpiValidationError`` as its cause. + """ + + rec = _Recorder() + rec.install(client) + client._v1 = _FakeV1() # type: ignore[assignment] + with pytest.raises(RuntimeError, match="require an 'id'") as excinfo: + await client.create_kb_article( + PostKBArticle(name="P", content="c", categories=[IdNameRef(name="Parrots")]) + ) + assert not any(c["method"] == "DELETE" for c in rec.calls) + assert isinstance(excinfo.value.__cause__, GlpiValidationError) + assert isinstance(excinfo.value.__cause__, ValueError) + + +async def test_create_kb_article_empty_categories_skips_v1(client: Any) -> None: + rec = _Recorder() + rec.install(client) + assert client._v1 is None # no v1 configured + new_id = await client.create_kb_article( + PostKBArticle(name="P", content="c", categories=[]) + ) + assert new_id == 88 # empty list is a no-op on create; no v1 needed + assert not any(c["method"] == "DELETE" for c in rec.calls) # no legacy call + + +async def test_update_kb_article_applies_categories_via_v1(client: Any) -> None: + rec = _Recorder() + rec.install(client) + fake = _FakeV1() + client._v1 = fake # type: ignore[assignment] + await client.update_kb_article(5, PatchKBArticle(categories=[IdNameRef(id=14)])) + assert rec.calls[0]["method"] == "PATCH" + assert fake.calls[0]["path"] == "KnowbaseItem/5" + assert fake.calls[0]["json_body"] == {"input": {"_categories": [14]}} + + +async def test_update_kb_article_without_categories_skips_v1(client: Any) -> None: + rec = _Recorder() + rec.install(client) + assert client._v1 is None + await client.update_kb_article(5, PatchKBArticle(is_pinned=True)) # no RuntimeError + assert rec.calls[0]["method"] == "PATCH" + + +async def test_update_kb_article_category_failure_does_not_roll_back( + client: Any, +) -> None: + rec = _Recorder() + rec.install(client) + client._v1 = _FakeV1(error=ValueError("boom")) # type: ignore[assignment] + with pytest.raises(ValueError, match="boom"): + await client.update_kb_article(5, PatchKBArticle(categories=[IdNameRef(id=14)])) + # Update is intentionally non-atomic: the v2 patch stays, no rollback delete. + assert not any(c["method"] == "DELETE" for c in rec.calls) + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_kb_article(1), + ], +) +async def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_article(PostKBArticle(name="x")), + ], +) +async def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_article(1, PatchKBArticle(name="x")), + ], +) +async def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_article(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py new file mode 100644 index 0000000..4fe4cea --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_category.py @@ -0,0 +1,185 @@ +"""Recorder-based unit tests for ``KBCategoryMixin``.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchKBCategory, PostKBCategory +from glpi_python_client._async._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse + + +class _Recorder: + """Transport recorder returning canned FakeResponse objects.""" + + def __init__(self, *, get_payload: Any = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + + def install(self, client: Any) -> None: + async def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "endpoint": endpoint, + "params": params, + "skip_entity": skip_entity, + } + ) + return FakeResponse(status_code=200, payload=self._get_payload) + + async def _post( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "POST", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=201, payload={"id": 55}) + + async def _patch( + endpoint: str, json_body: dict[str, Any] | None = None + ) -> FakeResponse: + self.calls.append( + {"method": "PATCH", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + async def _delete( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "DELETE", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + client._get_request = _get # type: ignore[method-assign] + client._post_request = _post # type: ignore[method-assign] + client._update_request = _patch # type: ignore[method-assign] + client._delete_request = _delete # type: ignore[method-assign] + + +async def test_search_kb_categories_forwards_filter_and_language(client: Any) -> None: + rec = _Recorder(get_payload=[{"id": 1, "name": "Network"}]) + rec.install(client) + result = await client.search_kb_categories( + "name==Network", limit=5, start=2, sort="name asc", language="fr_FR" + ) + assert result[0].id == 1 + call = rec.calls[0] + assert call["endpoint"] == "Knowledgebase/Category" + assert call["params"]["filter"] == "name==Network" + assert call["params"]["limit"] == 5 + assert call["params"]["start"] == 2 + assert call["params"]["sort"] == "name asc" + assert call["params"]["language"] == "fr_FR" + + +async def test_get_kb_category_targets_per_id_endpoint(client: Any) -> None: + rec = _Recorder(get_payload={"id": 9, "name": "Network"}) + rec.install(client) + category = await client.get_kb_category(9) + assert category.id == 9 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Category/9" + + +async def test_create_kb_category_returns_new_id(client: Any) -> None: + rec = _Recorder() + rec.install(client) + new_id = await client.create_kb_category(PostKBCategory(name="Network")) + assert new_id == 55 + call = rec.calls[0] + assert call["method"] == "POST" + assert call["endpoint"] == "Knowledgebase/Category" + assert call["json"] == {"name": "Network"} + + +async def test_update_kb_category_sends_patch(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.update_kb_category(9, PatchKBCategory(comment="moved")) + call = rec.calls[0] + assert call["method"] == "PATCH" + assert call["endpoint"] == "Knowledgebase/Category/9" + assert call["json"] == {"comment": "moved"} + + +async def test_delete_kb_category_with_force(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.delete_kb_category(9, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Knowledgebase/Category/9" + assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_kb_category(1), + ], +) +async def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_category(PostKBCategory(name="x")), + ], +) +async def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_category(1, PatchKBCategory(name="x")), + ], +) +async def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_category(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_comment.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_comment.py new file mode 100644 index 0000000..4ad7501 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_comment.py @@ -0,0 +1,181 @@ +"""Recorder-based unit tests for ``KBArticleCommentMixin``.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import ( + PatchKBArticleComment, + PostKBArticleComment, +) +from glpi_python_client._async._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse + + +class _Recorder: + def __init__(self, *, get_payload: Any = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + + def install(self, client: Any) -> None: + async def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "endpoint": endpoint, + "params": params, + "skip_entity": skip_entity, + } + ) + return FakeResponse(status_code=200, payload=self._get_payload) + + async def _post( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "POST", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=201, payload={"id": 77}) + + async def _patch( + endpoint: str, json_body: dict[str, Any] | None = None + ) -> FakeResponse: + self.calls.append( + {"method": "PATCH", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + async def _delete( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + {"method": "DELETE", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=204, payload={}) + + client._get_request = _get # type: ignore[method-assign] + client._post_request = _post # type: ignore[method-assign] + client._update_request = _patch # type: ignore[method-assign] + client._delete_request = _delete # type: ignore[method-assign] + + +async def test_list_kb_article_comments_targets_nested_endpoint(client: Any) -> None: + rec = _Recorder(get_payload=[{"id": 1, "comment": "hi"}]) + rec.install(client) + comments = await client.list_kb_article_comments(5) + assert comments[0].id == 1 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Comment" + + +async def test_get_kb_article_comment_targets_per_id_endpoint(client: Any) -> None: + rec = _Recorder(get_payload={"id": 7, "comment": "hi"}) + rec.install(client) + comment = await client.get_kb_article_comment(5, 7) + assert comment.id == 7 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Comment/7" + + +async def test_create_kb_article_comment_returns_id(client: Any) -> None: + rec = _Recorder() + rec.install(client) + new_id = await client.create_kb_article_comment( + 5, PostKBArticleComment(comment="hi") + ) + assert new_id == 77 + call = rec.calls[0] + assert call["endpoint"] == "Knowledgebase/Article/5/Comment" + assert call["json"] == {"comment": "hi"} + + +async def test_update_kb_article_comment_sends_patch(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.update_kb_article_comment( + 5, 7, PatchKBArticleComment(comment="edited") + ) + call = rec.calls[0] + assert call["method"] == "PATCH" + assert call["endpoint"] == "Knowledgebase/Article/5/Comment/7" + + +async def test_delete_kb_article_comment_with_force(client: Any) -> None: + rec = _Recorder() + rec.install(client) + await client.delete_kb_article_comment(5, 7, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Knowledgebase/Article/5/Comment/7" + assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_kb_article_comments(1), + lambda c: c.get_kb_article_comment(1, 2), + ], +) +async def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_article_comment(1, PostKBArticleComment(comment="x")), + ], +) +async def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_article_comment(1, 2, PatchKBArticleComment(comment="x")), + ], +) +async def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_article_comment(1, 2, force=True), + ], +) +async def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/knowledgebase/tests/test_revision.py b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_revision.py new file mode 100644 index 0000000..7711ae1 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/tests/test_revision.py @@ -0,0 +1,83 @@ +"""Recorder-based unit tests for the read-only ``KBArticleRevisionMixin``.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client._async._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse + + +class _Recorder: + def __init__(self, *, get_payload: Any = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + + def install(self, client: Any) -> None: + async def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append({"method": "GET", "endpoint": endpoint, "params": params}) + return FakeResponse(status_code=200, payload=self._get_payload) + + client._get_request = _get # type: ignore[method-assign] + + +async def test_list_kb_article_revisions_default_language(client: Any) -> None: + rec = _Recorder(get_payload=[{"id": 11, "revision": 2}]) + rec.install(client) + revisions = await client.list_kb_article_revisions(5) + assert revisions[0].revision == 2 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Revision" + + +async def test_list_kb_article_revisions_with_language_uses_path_segment( + client: Any, +) -> None: + rec = _Recorder(get_payload=[{"id": 11, "revision": 2}]) + rec.install(client) + await client.list_kb_article_revisions(5, language="fr_FR") + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/fr_FR/Revision" + + +async def test_get_kb_article_revision_default_language(client: Any) -> None: + rec = _Recorder(get_payload={"id": 11, "revision": 2}) + rec.install(client) + revision = await client.get_kb_article_revision(5, 2) + assert revision.revision == 2 + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Revision/2" + + +async def test_get_kb_article_revision_with_language(client: Any) -> None: + rec = _Recorder(get_payload={"id": 11, "revision": 2}) + rec.install(client) + await client.get_kb_article_revision(5, 2, language="fr_FR") + assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/fr_FR/Revision/2" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# +# The revision mixin is read-only, so it takes only the read share of the +# shared failure suites; there is no create, update, or delete call to take. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_kb_article_revisions(1), + lambda c: c.get_kb_article_revision(1, 2), + ], +) +async def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/management/tests/__init__.py b/glpi_python_client/_async/clients/api/management/tests/__init__.py new file mode 100644 index 0000000..898ad37 --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the document management endpoint mixin.""" diff --git a/glpi_python_client/_async/clients/api/management/tests/test_document.py b/glpi_python_client/_async/clients/api/management/tests/test_document.py new file mode 100644 index 0000000..4a1bd31 --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/tests/test_document.py @@ -0,0 +1,226 @@ +"""Unit tests for the ``Management/Document`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, download, and +upload for GLPI documents, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. The upload tests +additionally stub the legacy v1 session used for binary uploads. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import GlpiValidationError, PatchDocument, PostDocument +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Documents (management) +# --------------------------------------------------------------------------- + + +async def test_search_documents_filter_and_pagination(client: Any) -> None: + """``search_documents`` forwards the filter, limit, start, and skip_entity.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "doc"}]) + rec.install(client) + docs = await client.search_documents("name==*manual*", limit=10, start=20) + assert len(docs) == 1 + call = rec.calls[0] + assert call["endpoint"] == "Management/Document" + assert call["skip_entity"] is True + assert call["params"]["limit"] == 10 + assert call["params"]["start"] == 20 + assert call["params"]["filter"] == "name==*manual*" + + +async def test_get_document_endpoint(client: Any) -> None: + """``get_document`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 3, "name": "doc"}) + rec.install(client) + document = await client.get_document(3) + assert document.id == 3 + assert rec.calls[0]["endpoint"] == "Management/Document/3" + + +async def test_create_document_returns_id(client: Any) -> None: + """``create_document`` returns the new id and skips entity.""" + + rec = TransportRecorder(post_payload={"id": 77}) + rec.install(client) + document_id = await client.create_document(PostDocument(name="manual")) + assert document_id == 77 + assert rec.calls[0]["endpoint"] == "Management/Document" + assert rec.calls[0]["skip_entity"] is True + + +async def test_update_document_patches_endpoint(client: Any) -> None: + """``update_document`` issues PATCH on the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_document(3, PatchDocument(name="x")) + assert rec.calls[0]["endpoint"] == "Management/Document/3" + + +async def test_delete_document_with_force(client: Any) -> None: + """``delete_document(force=True)`` adds the body and skips entity.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_document(3, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Management/Document/3" + assert call["json"] == {"force": True} + assert call["skip_entity"] is True + + +async def test_download_document_returns_bytes(client: Any) -> None: + """``download_document_content`` returns the response bytes.""" + + rec = TransportRecorder( + get_status=200, get_payload={"ignored": True}, get_content=b"\x00ZZ" + ) + rec.install(client) + content = await client.download_document_content(3) + assert content == b"\x00ZZ" + assert rec.calls[0]["endpoint"] == "Management/Document/3/Download" + + +async def test_download_document_raises_on_failure(client: Any) -> None: + """A non-200 download status raises ``ValueError``.""" + + rec = TransportRecorder(get_status=404, get_payload={"err": "missing"}) + rec.install(client) + with pytest.raises(ValueError): + await client.download_document_content(3) + + +async def test_upload_document_requires_filename(client: Any) -> None: + """``upload_document`` rejects an empty filename before any HTTP call. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="filename") as excinfo: + await client.upload_document(filename="", content=b"x") + assert isinstance(excinfo.value, ValueError) + + +async def test_upload_document_dispatches_to_v1(client: Any) -> None: + """``upload_document`` forwards arguments to the configured v1 session.""" + + captured: dict[str, Any] = {} + + class _FakeV1: + """Stand-in for the legacy v1 session used by document upload.""" + + async def upload_document( + self, + filename: str, + content: bytes, + mime_type: str, + *, + document_name: str | None, + ticket_id: int | None, + entity_id: int | None, + ) -> dict[str, object]: + captured.update( + { + "filename": filename, + "content": content, + "mime_type": mime_type, + "document_name": document_name, + "ticket_id": ticket_id, + "entity_id": entity_id, + } + ) + return {"id": 1} + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + client._v1 = _FakeV1() # type: ignore[assignment] + result = await client.upload_document( + filename="a.txt", + content=b"abc", + mime_type="text/plain", + document_name="DocA", + ticket_id=5, + entity_id=2, + ) + + assert result == {"id": 1} + assert captured["filename"] == "a.txt" + assert captured["ticket_id"] == 5 + assert captured["entity_id"] == 2 + + +async def test_upload_document_without_v1_raises(client: Any) -> None: + """``upload_document`` requires a v1 session to be configured.""" + + with pytest.raises(RuntimeError): + await client.upload_document( + filename="a.bin", + content=b"x", + ) + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_document(1), + ], +) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_document(1, PatchDocument(name="x")), + ], +) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_document(1, force=True), + ], +) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) diff --git a/glpi_python_client/_async/clients/api/plugins/tests/__init__.py b/glpi_python_client/_async/clients/api/plugins/tests/__init__.py new file mode 100644 index 0000000..620743a --- /dev/null +++ b/glpi_python_client/_async/clients/api/plugins/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the GLPI plugin endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/plugins/tests/test_fields.py b/glpi_python_client/_async/clients/api/plugins/tests/test_fields.py new file mode 100644 index 0000000..8636bb8 --- /dev/null +++ b/glpi_python_client/_async/clients/api/plugins/tests/test_fields.py @@ -0,0 +1,405 @@ +"""Unit tests for the GLPI ``Fields`` plugin client mixin.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from glpi_python_client import GlpiProtocolError, GlpiValidationError +from glpi_python_client._async.clients.api.plugins._fields import ( + _container_targets_itemtype, + _extract_row_id, + _value_itemtype_for, +) +from glpi_python_client.models.api_schema.plugins import ( + GetPluginFieldsContainer, +) + + +class _FakeV1: + """Stand-in for :class:`GLPIV1Session` that records ``request_json`` calls.""" + + def __init__(self, responses: list[object]) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "params": params, + "json_body": json_body, + "success_statuses": success_statuses, + "failure_message": failure_message, + } + ) + if not self.responses: + raise AssertionError( + f"Unexpected v1 call: {method} {path} (no more queued responses)" + ) + return self.responses.pop(0) + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def test_value_itemtype_naming() -> None: + """The value itemtype is built from parent type + lowercase container.""" + + assert _value_itemtype_for("Ticket", "extrainfo") == "PluginFieldsTicketextrainfo" + # Mixed-case names get normalised to lowercase to match the v1 routes. + assert ( + _value_itemtype_for("Ticket", "MyContainer") == "PluginFieldsTicketmycontainer" + ) + + +def test_container_targets_itemtype_parses_json_array() -> None: + """The JSON-encoded ``itemtypes`` string is parsed for filtering.""" + + container = GetPluginFieldsContainer( + id=1, name="x", itemtypes='["Ticket","Computer"]' + ) + assert _container_targets_itemtype(container, "Ticket") + assert not _container_targets_itemtype(container, "Problem") + + +def test_container_targets_itemtype_substring_fallback() -> None: + """A non-JSON ``itemtypes`` string falls back to a substring check.""" + + container = GetPluginFieldsContainer(id=1, name="x", itemtypes="Ticket") + assert _container_targets_itemtype(container, "Ticket") + + +def test_container_targets_itemtype_handles_empty() -> None: + """Containers with no ``itemtypes`` value never match.""" + + container = GetPluginFieldsContainer(id=1, name="x", itemtypes=None) + assert not _container_targets_itemtype(container, "Ticket") + + +def test_extract_row_id_parses_plugin_response() -> None: + """The plugin response shape ``[{"test
", + } + ] + ] + ) + client._v1 = fake # type: ignore[assignment] + rows = await client.list_item_plugin_field_rows("Ticket", 1234, "extrainfo") + assert rows[0].extra_payload == {"extrainfofield": "test
"} + assert fake.calls[0]["path"] == "Ticket/1234/PluginFieldsTicketextrainfo" + + +async def test_create_item_plugin_field_row_returns_new_id(client: Any) -> None: + """Create POSTs ``{"input": ...}`` and returns the new row id.""" + + fake = _FakeV1(responses=[[{"7": True, "message": ""}]]) + client._v1 = fake # type: ignore[assignment] + row_id = await client.create_item_plugin_field_row( + itemtype="Ticket", + items_id=99, + container_id=10, + container_name="extrainfo", + values={"extrainfofield": "x
"}, + entities_id=3, + ) + assert row_id == 7 + call = fake.calls[0] + assert call["method"] == "POST" + assert call["path"] == "PluginFieldsTicketextrainfo" + assert call["json_body"] == { + "input": { + "items_id": 99, + "itemtype": "Ticket", + "plugin_fields_containers_id": 10, + "extrainfofield": "x
", + "entities_id": 3, + } + } + + +async def test_update_item_plugin_field_row_puts_partial_body(client: Any) -> None: + """Update PUTs ``{"input": {"id": row_id, ...}}`` against the row endpoint.""" + + fake = _FakeV1(responses=[[{"1": True, "message": ""}]]) + client._v1 = fake # type: ignore[assignment] + await client.update_item_plugin_field_row( + itemtype="Ticket", + container_name="extrainfo", + row_id=1, + values={"extrainfofield": "updated
"}, + ) + call = fake.calls[0] + assert call["method"] == "PUT" + assert call["path"] == "PluginFieldsTicketextrainfo/1" + assert call["json_body"] == {"input": {"id": 1, "extrainfofield": "updated
"}} + + +async def test_get_ticket_custom_fields_aggregates_containers(client: Any) -> None: + """The high-level helper aggregates per-container values into one mapping.""" + + fake = _FakeV1( + responses=[ + # 1. list_plugin_fields_containers(Ticket) + [ + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, + {"id": 2, "name": "secondary", "itemtypes": '["Ticket"]'}, + {"id": 3, "name": "ignored", "itemtypes": '["Computer"]'}, + ], + # 2. list_item_plugin_field_rows extrainfo + [ + { + "id": 1, + "items_id": 1234, + "itemtype": "Ticket", + "plugin_fields_containers_id": 10, + "entities_id": 0, + "extrainfofield": "test
", + } + ], + # 3. list_item_plugin_field_rows secondary -> no row yet + [], + ] + ) + client._v1 = fake # type: ignore[assignment] + result = await client.get_ticket_custom_fields(1234) + assert result == {"extrainfo": {"extrainfofield": "test
"}} + + +async def test_set_ticket_custom_fields_updates_existing_row(client: Any) -> None: + """When a row exists the high-level helper PATCHes it in place.""" + + fake = _FakeV1( + responses=[ + # list containers + [ + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, + ], + # list fields for container 10 + [ + { + "id": 11, + "name": "extrainfofield", + "plugin_fields_containers_id": 10, + } + ], + # list existing rows -> one row already present + [ + { + "id": 1, + "items_id": 1234, + "itemtype": "Ticket", + "plugin_fields_containers_id": 10, + } + ], + # PUT response + [{"1": True, "message": ""}], + ] + ) + client._v1 = fake # type: ignore[assignment] + await client.set_ticket_custom_fields( + 1234, {"extrainfo": {"extrainfofield": "new
"}} + ) + methods = [c["method"] for c in fake.calls] + assert methods == ["GET", "GET", "GET", "PUT"] + put = fake.calls[-1] + assert put["json_body"] == {"input": {"id": 1, "extrainfofield": "new
"}} + + +async def test_set_ticket_custom_fields_creates_when_missing(client: Any) -> None: + """When no row exists the high-level helper POSTs a new one.""" + + fake = _FakeV1( + responses=[ + [ + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, + ], + [ + { + "id": 11, + "name": "extrainfofield", + "plugin_fields_containers_id": 10, + } + ], + [], # no existing rows + [{"99": True, "message": ""}], # POST response + ] + ) + client._v1 = fake # type: ignore[assignment] + await client.set_ticket_custom_fields( + 1234, {"extrainfo": {"extrainfofield": "new
"}} + ) + methods = [c["method"] for c in fake.calls] + assert methods == ["GET", "GET", "GET", "POST"] + post = fake.calls[-1] + assert post["json_body"] == { + "input": { + "items_id": 1234, + "itemtype": "Ticket", + "plugin_fields_containers_id": 10, + "extrainfofield": "new
", + } + } + + +async def test_set_ticket_custom_fields_rejects_unknown_container(client: Any) -> None: + """A typo in the container name raises before any write. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + fake = _FakeV1(responses=[[{"id": 10, "name": "real", "itemtypes": '["Ticket"]'}]]) + client._v1 = fake # type: ignore[assignment] + with pytest.raises( + GlpiValidationError, match="Unknown plugin-fields container" + ) as excinfo: + await client.set_ticket_custom_fields(1234, {"typo": {"any": "value"}}) + # No mutation was sent. + assert all(c["method"] == "GET" for c in fake.calls) + assert isinstance(excinfo.value, ValueError) + + +async def test_set_ticket_custom_fields_rejects_container_without_id( + client: Any, +) -> None: + """A matched container with no ``id`` raises before any write. + + The container came from the server's own + :meth:`~glpi_python_client._async.clients.api.plugins._fields.PluginFieldsMixin.list_plugin_fields_containers` + response, so a missing ``id`` is a server-side contract violation, not + a caller mistake: ``GlpiProtocolError``. It still inherits + ``ValueError`` so existing callers that catch the broader type keep + working. + """ + + fake = _FakeV1(responses=[[{"name": "extrainfo", "itemtypes": '["Ticket"]'}]]) + client._v1 = fake # type: ignore[assignment] + with pytest.raises(GlpiProtocolError, match="has no id") as excinfo: + await client.set_ticket_custom_fields( + 1234, {"extrainfo": {"extrainfofield": "value"}} + ) + assert all(c["method"] == "GET" for c in fake.calls) + assert isinstance(excinfo.value, ValueError) + + +async def test_set_ticket_custom_fields_rejects_unknown_field(client: Any) -> None: + """A typo in the field name raises before any write. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + fake = _FakeV1( + responses=[ + [{"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}], + [ + { + "id": 11, + "name": "extrainfofield", + "plugin_fields_containers_id": 10, + } + ], + ] + ) + client._v1 = fake # type: ignore[assignment] + with pytest.raises(GlpiValidationError, match="Unknown field") as excinfo: + await client.set_ticket_custom_fields(1234, {"extrainfo": {"typo": "value"}}) + assert isinstance(excinfo.value, ValueError) + + +async def test_set_ticket_custom_fields_with_empty_mapping_is_noop( + client: Any, +) -> None: + """Passing an empty mapping performs no HTTP call.""" + + fake = _FakeV1(responses=[]) + client._v1 = fake # type: ignore[assignment] + await client.set_ticket_custom_fields(1234, {}) + assert fake.calls == [] diff --git a/glpi_python_client/_async/clients/client.py b/glpi_python_client/_async/clients/client.py index 2fc4316..bca6b81 100644 --- a/glpi_python_client/_async/clients/client.py +++ b/glpi_python_client/_async/clients/client.py @@ -105,7 +105,7 @@ async def __aenter__(self) -> Self: Returns ------- - AsyncGlpiClient + Self The client itself, suitable for chaining method calls. """ diff --git a/glpi_python_client/_async/clients/commons/_config.py b/glpi_python_client/_async/clients/commons/_config.py index 0de514a..8a7300e 100644 --- a/glpi_python_client/_async/clients/commons/_config.py +++ b/glpi_python_client/_async/clients/commons/_config.py @@ -43,7 +43,7 @@ def __call__(self, *, verify_ssl: bool) -> httpx.AsyncClient: @dataclass(frozen=True) class ClientResources: - """Runtime resources owned by one async ``GlpiClient`` instance. + """Runtime resources owned by one client instance. The bundle keeps shared HTTP session, token manager, and optional v1 upload session tied together so the client can release them as a unit. @@ -85,8 +85,9 @@ def build_http_session(*, verify_ssl: bool) -> httpx.AsyncClient: Returns ------- - httpx.AsyncClient - A client configured for the requested SSL policy. + httpx.Client | httpx.AsyncClient + A client configured for the requested SSL policy -- the httpx + client matching this surface, as named in the signature. """ return httpx.AsyncClient( @@ -111,7 +112,7 @@ def build_client_resources( v1_app_token: str | None, session_factory: SessionFactory | None = None, ) -> ClientResources: - """Build the shared resources required by one async client instance. + """Build the shared resources required by one client instance. The helper validates the API URL, configures SSL behaviour, builds the OAuth token manager, and optionally instantiates the legacy v1 session diff --git a/glpi_python_client/_async/clients/commons/tests/__init__.py b/glpi_python_client/_async/clients/commons/tests/__init__.py new file mode 100644 index 0000000..a2432b6 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the shared client building blocks.""" diff --git a/glpi_python_client/_async/clients/commons/tests/test_config.py b/glpi_python_client/_async/clients/commons/tests/test_config.py new file mode 100644 index 0000000..5c47974 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_config.py @@ -0,0 +1,162 @@ +"""Unit tests for client configuration parsing and validation. + +These helpers normalise the API URL, validate the legacy v1 credential +pair, and coerce the GLPI_* environment variables. They are pure +functions: no client is constructed and nothing is awaited. +""" + +from __future__ import annotations + +import pytest + +from glpi_python_client import GlpiValidationError +from glpi_python_client._async.clients.commons._config import ( + build_client_env_config, + normalize_client_api_url, + parse_optional_env_bool, + parse_optional_env_int, + validate_v1_document_config, +) + + +def test_normalize_client_api_url_strips_trailing_slash() -> None: + """The helper trims one trailing slash for consistent endpoint joins.""" + + assert ( + normalize_client_api_url("https://glpi.test/api.php/v2/", client_name="X") + == "https://glpi.test/api.php/v2" + ) + + +def test_normalize_client_api_url_rejects_missing_value() -> None: + """Missing or empty URL raises ``GlpiValidationError`` with the client name. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="X requires glpi_api_url") as exc1: + normalize_client_api_url(None, client_name="X") + assert isinstance(exc1.value, ValueError) + with pytest.raises(GlpiValidationError, match="X requires glpi_api_url") as exc2: + normalize_client_api_url("", client_name="X") + assert isinstance(exc2.value, ValueError) + with pytest.raises(GlpiValidationError, match="X requires glpi_api_url") as exc3: + normalize_client_api_url(123, client_name="X") # type: ignore[arg-type] + assert isinstance(exc3.value, ValueError) + + +def test_validate_v1_document_config_rejects_partial_pair() -> None: + """Either both v1 values are present or both are absent. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises( + GlpiValidationError, match="v1_base_url and v1_user_token" + ) as exc1: + validate_v1_document_config(v1_base_url="https://x", v1_user_token=None) + assert isinstance(exc1.value, ValueError) + with pytest.raises( + GlpiValidationError, match="v1_base_url and v1_user_token" + ) as exc2: + validate_v1_document_config(v1_base_url=None, v1_user_token="t") + assert isinstance(exc2.value, ValueError) + + +def test_validate_v1_document_config_allows_complete_pair() -> None: + """A complete v1 pair (or no v1 at all) does not raise.""" + + validate_v1_document_config(v1_base_url=None, v1_user_token=None) + validate_v1_document_config(v1_base_url="https://x", v1_user_token="t") + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + (5, 5), + ("7", 7), + ], +) +def test_parse_optional_env_int(value: object, expected: int | None) -> None: + """Environment integer parsing accepts None, int, and numeric strings.""" + + assert parse_optional_env_int(value) == expected + + +def test_parse_optional_env_int_rejects_other_types() -> None: + """Non-string non-int inputs raise ``TypeError``.""" + + with pytest.raises(TypeError): + parse_optional_env_int(1.5) + + +def test_parse_optional_env_int_rejects_unparseable_string() -> None: + """A non-numeric string (e.g. ``GLPI_TIMEOUT=abc``) raises ``GlpiValidationError``. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working, and the original ``int()`` + ``ValueError`` is chained via ``from`` rather than swallowed. + """ + + with pytest.raises(GlpiValidationError, match="abc") as excinfo: + parse_optional_env_int("abc") + assert isinstance(excinfo.value, ValueError) + assert isinstance(excinfo.value.__cause__, ValueError) + + +@pytest.mark.parametrize( + ("value", "default", "expected"), + [ + (None, True, True), + (None, False, False), + (True, False, True), + ("1", False, True), + ("yes", False, True), + ("ON", False, True), + ("0", True, False), + ("false", True, False), + ("OFF", True, False), + ], +) +def test_parse_optional_env_bool_truthy_and_falsy( + value: object, default: bool, expected: bool +) -> None: + """Boolean parsing handles strings, native booleans, and the default.""" + + assert parse_optional_env_bool(value, default=default) is expected + + +def test_parse_optional_env_bool_rejects_unknown_string() -> None: + """Unknown strings raise ``GlpiValidationError``. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError) as excinfo: + parse_optional_env_bool("maybe", default=False) + assert isinstance(excinfo.value, ValueError) + + +def test_parse_optional_env_bool_rejects_other_types() -> None: + """Non-string non-bool inputs raise ``TypeError``.""" + + with pytest.raises(TypeError): + parse_optional_env_bool(1, default=False) + + +def test_build_client_env_config_overrides_win() -> None: + """Explicit overrides win over environment values.""" + + env = {"GLPI_API_URL": "https://env", "GLPI_VERIFY_SSL": "false"} + config = build_client_env_config( + prefix="GLPI_", + env=env, + overrides={"glpi_api_url": "https://override"}, + ) + assert config["glpi_api_url"] == "https://override" + assert config["verify_ssl"] is False + assert config["language"] == "en_GB" diff --git a/glpi_python_client/_async/clients/commons/tests/test_constants.py b/glpi_python_client/_async/clients/commons/tests/test_constants.py new file mode 100644 index 0000000..dcb8101 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_constants.py @@ -0,0 +1,14 @@ +"""Unit tests for the endpoint constant table.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons import _constants + + +def test_knowledgebase_endpoint_constants() -> None: + """The KB endpoint constants match the 2.3.0 contract resource paths.""" + + assert _constants.KB_ARTICLE_ENDPOINT == "Knowledgebase/Article" + assert _constants.KB_CATEGORY_ENDPOINT == "Knowledgebase/Category" + assert _constants.KB_COMMENT_SUFFIX == "Comment" + assert _constants.KB_REVISION_SUFFIX == "Revision" diff --git a/glpi_python_client/_async/clients/commons/tests/test_filters.py b/glpi_python_client/_async/clients/commons/tests/test_filters.py new file mode 100644 index 0000000..617f60c --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_filters.py @@ -0,0 +1,73 @@ +"""Unit tests for :mod:`glpi_python_client._async.clients.commons._filters`.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._filters import ( + escape_rsql_like_value, + escape_rsql_text_value, + rsql_all_filter, + rsql_any_filter, + rsql_contains_filter, + rsql_equals_filter, +) + + +def test_rsql_equals_filter_quotes_strings() -> None: + """``rsql_equals_filter`` quotes string values for the GLPI RSQL syntax.""" + + assert rsql_equals_filter("name", "alice") == 'name=="alice"' + + +def test_rsql_contains_filter_uses_like_operator() -> None: + """``rsql_contains_filter`` uses the GLPI ``=like=`` operator with wildcards.""" + + expression = rsql_contains_filter("name", "ali") + assert expression == 'name=like="*ali*"' + + +def test_rsql_all_filter_joins_with_semicolons() -> None: + """``rsql_all_filter`` joins non-empty parts with ``;``.""" + + assert rsql_all_filter("a==1", "", "b==2") == "a==1;b==2" + + +def test_rsql_any_filter_joins_with_commas() -> None: + """``rsql_any_filter`` ORs parts and parenthesises the group.""" + + assert rsql_any_filter("a==1", None, "b==2") == "(a==1,b==2)" + + +def test_rsql_any_filter_single_part_is_not_wrapped() -> None: + """A lone fragment needs no group and is returned unchanged.""" + + assert rsql_any_filter(None, "a==1", "") == "a==1" + + +def test_rsql_any_filter_group_survives_an_and_join() -> None: + """The OR group keeps its AND clauses when nested in ``rsql_all_filter``. + + RSQL binds ``;`` tighter than ``,``. Without the parentheses this + composes to ``date;e==1,e==2``, which the server reads as + ``(date AND e==1) OR e==2`` -- so every ``e==2`` record matches + regardless of the date window. Measured against a live GLPI 11 + instance, that returned 16,245 tickets where the correct answer, + reproduced by the parenthesised form, was 1,552. + """ + + combined = rsql_all_filter( + "date_creation=ge=2026-01-01", + rsql_any_filter("entity.id==1", "entity.id==2"), + ) + assert combined == "date_creation=ge=2026-01-01;(entity.id==1,entity.id==2)" + + +def test_escape_rsql_like_value_escapes_special_characters() -> None: + """The helper escapes the wildcard and quote characters used by RSQL.""" + + assert escape_rsql_like_value('a*b"c') == 'a\\*b\\"c' + + +def test_escape_rsql_text_value_escapes_quotes() -> None: + """``escape_rsql_text_value`` escapes double quotes inside the literal.""" + + assert escape_rsql_text_value('a"b') == 'a\\"b' diff --git a/glpi_python_client/_async/clients/commons/tests/test_http.py b/glpi_python_client/_async/clients/commons/tests/test_http.py new file mode 100644 index 0000000..2862214 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_http.py @@ -0,0 +1,197 @@ +"""Unit tests for :mod:`glpi_python_client._async.clients.commons._http`. + +The tests cover the small request and response helper utilities used by the +transport and the per-endpoint mixins. +""" + +from __future__ import annotations + +import json + +import pytest + +from glpi_python_client import GlpiNotFoundError, GlpiProtocolError, GlpiValidationError +from glpi_python_client._async.clients.commons._http import ( + build_request_headers, + build_request_url, + ensure_response_status, + list_payload_items, + request_param_value, + request_params, + require_access_token, + require_response_int, + response_json_mapping, +) +from glpi_python_client.testing.utils import FakeResponse + + +def test_request_param_value_normalises_supported_types() -> None: + """The helper renders every value the way the wire format expects. + + Numbers and strings pass through untouched. ``bool`` and ``bytes`` are + converted deliberately, because the underlying HTTP libraries disagree + about them: ``httpx`` renders booleans lowercase and stringifies a + ``bytes`` object into its Python repr (``b'x'``), where the previous + ``requests``-based transport emitted ``True`` and the decoded text. + Normalising here keeps the emitted query string identical regardless of + which library is installed. + """ + + assert request_param_value(7) == 7 + assert request_param_value(1.5) == 1.5 + assert request_param_value("hello") == "hello" + assert request_param_value(True) == "True" + assert request_param_value(False) == "False" + assert request_param_value(b"raw") == "raw" + assert request_param_value(object()) != "" + + +def test_request_params_drops_none_values() -> None: + """``None`` parameter values are excluded from the produced query mapping. + + This is a correctness guarantee, not a tidiness one. ``requests`` omitted + a ``None``-valued key from the query string entirely; ``httpx`` encodes it + as a valueless ``key=``. GLPI does not treat those the same — an empty + filter or search value matches *everything* — so forwarding the key would + silently widen a query rather than leave it unconstrained. + """ + + cleaned = request_params({"limit": 10, "filter": None, "force": True}) + assert cleaned == {"limit": 10, "force": "True"} + assert "filter" not in cleaned + + +def test_build_request_url_concatenates_base_and_endpoint() -> None: + """The helper joins the base URL and endpoint with a single slash.""" + + assert ( + build_request_url("https://glpi.test/api/v2", "Assistance/Ticket") + == "https://glpi.test/api/v2/Assistance/Ticket" + ) + + +def test_build_request_headers_include_entity_when_not_skipped() -> None: + """The default headers include the ``GLPI-Entity`` header when configured.""" + + headers = build_request_headers( + access_token="token", + language="fr_FR", + glpi_entity=12, + glpi_profile=4, + entity_recursive=True, + include_content_type=True, + skip_entity=False, + ) + assert headers["Authorization"] == "Bearer token" + assert headers["Content-Type"] == "application/json" + assert headers["GLPI-Entity"] == "12" + assert headers["GLPI-Entity-Recursive"] == "true" + assert headers["GLPI-Profile"] == "4" + assert headers["Accept-Language"] == "fr_FR" + + +def test_build_request_headers_skip_entity_drops_entity_headers() -> None: + """When ``skip_entity`` is true the entity headers are omitted.""" + + headers = build_request_headers( + access_token="token", + language="en_GB", + glpi_entity=12, + glpi_profile=None, + entity_recursive=False, + skip_entity=True, + ) + assert "GLPI-Entity" not in headers + assert "GLPI-Entity-Recursive" not in headers + + +def test_require_access_token_rejects_missing_value() -> None: + """``require_access_token`` raises when the token is missing or empty.""" + + with pytest.raises(ValueError): + require_access_token(None) + + +def test_ensure_response_status_raises_for_unexpected_status() -> None: + """``ensure_response_status`` raises when the status is not whitelisted.""" + + response = FakeResponse(status_code=500, payload={"error": "boom"}) + with pytest.raises(ValueError): + ensure_response_status( + response, + success_statuses=(200,), + failure_message="Boom", + ) + + +def test_response_json_mapping_returns_dict() -> None: + """``response_json_mapping`` returns the JSON body when it is a mapping.""" + + response = FakeResponse(payload={"a": 1}) + assert response_json_mapping(response) == {"a": 1} + + +def test_require_response_int_returns_first_matching_key() -> None: + """``require_response_int`` returns the first integer-typed value.""" + + response = FakeResponse(payload={"id": 42}) + assert require_response_int(response, keys=("id",), missing_message="x") == 42 + + +def test_list_payload_items_handles_dict_payload() -> None: + """``list_payload_items`` returns an empty list for non-list payloads.""" + + assert list_payload_items({"data": [1, 2, 3]}) == [] + assert list_payload_items([{"id": 1}, 2]) == [{"id": 1}] + + +def test_fake_response_round_trip() -> None: + """The shared ``FakeResponse`` test helper round-trips JSON payloads.""" + + response = FakeResponse(payload={"hello": "world"}) + assert json.loads(response.text.replace("'", '"')) == {"hello": "world"} + + +def test_require_access_token_raises_protocol_error_when_missing() -> None: + """A missing token means the OAuth response was unusable, not a caller error.""" + + with pytest.raises(GlpiProtocolError) as excinfo: + require_access_token(None) + + assert isinstance(excinfo.value, ValueError) + assert str(excinfo.value) == "Failed to acquire access token for API request" + + +def test_require_response_int_raises_protocol_error_when_id_missing() -> None: + """A 2xx create response without a numeric id is a protocol failure.""" + + response = FakeResponse(status_code=201, payload={"nope": "x"}) + with pytest.raises(GlpiProtocolError) as excinfo: + require_response_int( + response, keys=("id",), missing_message="GLPI create returned no id" + ) + + assert isinstance(excinfo.value, ValueError) + assert str(excinfo.value) == "GLPI create returned no id" + + +def test_protocol_error_is_not_a_validation_error() -> None: + """A server-shape fault must not masquerade as a caller mistake.""" + + assert not issubclass(GlpiProtocolError, GlpiValidationError) + + +def test_4xx_raises_a_typed_status_error_from_ensure_response_status() -> None: + """The 4xx raise stays in ``ensure_response_status`` and is typed.""" + + response = FakeResponse(status_code=404, payload={}, text="nope") + with pytest.raises(GlpiNotFoundError) as excinfo: + ensure_response_status( + response, + success_statuses=(200, 206), + failure_message="Failed to fetch ticket 1", + ) + + assert excinfo.value.status_code == 404 + assert isinstance(excinfo.value, ValueError) + assert str(excinfo.value) == "Failed to fetch ticket 1: 404 nope" diff --git a/glpi_python_client/_async/clients/commons/tests/test_payloads.py b/glpi_python_client/_async/clients/commons/tests/test_payloads.py new file mode 100644 index 0000000..10a29ce --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_payloads.py @@ -0,0 +1,64 @@ +"""Unit tests for :mod:`glpi_python_client._async.clients.commons._payloads`.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._payloads import ( + model_from_payload, + model_to_payload, +) +from glpi_python_client.models.api_schema.administration._user import ( + GetUser, + PostUser, +) + + +def test_model_to_payload_excludes_none_and_extra_payload_meta() -> None: + """The helper drops ``None`` fields and the ``extra_payload`` meta key.""" + + user = PostUser(username="alice", realname=None) + body = model_to_payload(user) + assert body == {"username": "alice"} + + +def test_model_to_payload_merges_user_extra_payload() -> None: + """User-provided ``extra_payload`` keys are merged into the request body.""" + + user = PostUser(username="alice", extra_payload={"comment": "hi"}) + body = model_to_payload(user) + assert body["comment"] == "hi" + + +def test_model_from_payload_validates_response_data() -> None: + """``model_from_payload`` is a thin wrapper around ``model_validate``.""" + + parsed = model_from_payload(GetUser, {"id": 7, "username": "alice"}) + assert parsed.id == 7 + assert parsed.username == "alice" + + +def test_capture_extra_keys_passes_non_dict_input_through() -> None: + """The base validator returns non-mapping inputs untouched.""" + + # ``model_validate`` may receive non-mapping input (e.g. another model + # instance) and the ``_capture_extra_keys`` validator must short-circuit + # without trying to mutate it. Using an existing instance exercises the + # ``return data`` early-exit branch in ``_base.GlpiModel``. + original = PostUser(username="bob") + revalidated = PostUser.model_validate(original) + assert revalidated.username == "bob" + + +def test_capture_extra_keys_merges_with_existing_extra_payload_dict() -> None: + """Caller-provided ``extra_payload`` wins over keys captured from the body.""" + + user = PostUser.model_validate( + { + "username": "alice", + "stranger": "captured", + "extra_payload": {"caller_wins": True, "stranger": "explicit"}, + } + ) + assert user.extra_payload == { + "stranger": "explicit", + "caller_wins": True, + } diff --git a/glpi_python_client/_async/clients/commons/tests/test_transport.py b/glpi_python_client/_async/clients/commons/tests/test_transport.py new file mode 100644 index 0000000..b3d35a4 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/tests/test_transport.py @@ -0,0 +1,418 @@ +"""Unit tests for the GLPI transport mixin. + +The tests exercise the core dispatch path -- ``_ensure_token``, +``_send_request``, ``_execute_request``, and the four HTTP-verb helpers -- +using a real client with its session and auth stubbed out so no real +network call is made. + +Retry semantics for the v2 transport are folded in here too: 5xx is retried, +4xx is not. These tests are the regression net for the retry predicate. +Getting the predicate wrong disables retries silently -- nothing raises, +nothing fails, requests simply stop being retried. See the 0.4.0 plan-1 +notes. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +from tenacity import wait_fixed + +from glpi_python_client import ( + AsyncGlpiClient, + GlpiError, + GlpiServerError, + GlpiTimeoutError, + GlpiTransportError, +) +from glpi_python_client._async._testing import make_client +from glpi_python_client.testing.utils import FakeResponse + +_RETRIED_METHODS = ( + "_get_request", + "_post_request", + "_update_request", + "_delete_request", +) + + +@pytest.fixture +async def transport_client() -> AsyncIterator[Any]: + """Yield a client with auth and send_request pre-stubbed.""" + + c = make_client() + # Inject a ready access token and make ensure_token a no-op so the + # transport helpers can be called without network access. + c._auth.access_token = "test-token" + + async def _ensure_token() -> None: + return None + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + + # Stub _send_request at the seam level so _execute_request exercises the + # real header-building logic while returning a controlled response. + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload={"id": 1}) + + c._send_request = _send # type: ignore[method-assign] + yield c + await c.close() + + +async def test_ensure_token_calls_auth_manager(monkeypatch: pytest.MonkeyPatch) -> None: + """``_ensure_token`` invokes ``_auth.ensure_token`` on an open client.""" + + c = make_client() + called: list[bool] = [] + + async def _ensure_token() -> None: + called.append(True) + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + await c._ensure_token() + assert called + await c.close() + + +async def test_send_request_dispatches_through_session_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_send_request`` routes through ``session.request`` with an upper verb. + + The verb is passed as a *value* to ``request`` rather than being looked + up as a per-verb attribute: that is the one call shape ``requests`` and + ``httpx`` share, so the transport swap does not have to reason about + dynamic attribute lookup. + """ + + c = make_client() + fake = FakeResponse(status_code=200, payload={}) + seen: dict[str, object] = {} + + async def _request(method: str, url: str, **kw: object) -> FakeResponse: + seen.update({"method": method, "url": url, "kw": kw}) + return fake + + monkeypatch.setattr(c._session, "request", _request) + result = await c._send_request( + "get", "https://glpi.example.test/api.php/v2/test", timeout=30 + ) + assert result is fake + assert seen["method"] == "GET" + assert seen["url"] == "https://glpi.example.test/api.php/v2/test" + assert seen["kw"] == {"timeout": 30} + await c.close() + + +async def test_send_request_does_not_use_per_verb_attributes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stubbing only ``session.get`` must no longer intercept a GET. + + This pins the seam: if dispatch ever regresses to + ``getattr(session, verb)`` this test fails, because the per-verb + attribute would be used again. + """ + + c = make_client() + sentinel = FakeResponse(status_code=418, payload={}) + monkeypatch.setattr(c._session, "get", lambda url, **kw: sentinel) + captured: dict[str, object] = {} + + async def _request(method: str, url: str, **kw: object) -> FakeResponse: + captured["used"] = True + return FakeResponse(status_code=200, payload={}) + + monkeypatch.setattr(c._session, "request", _request) + result = await c._send_request("get", "https://glpi.example.test/api.php/v2/test") + assert captured.get("used") is True + assert result is not sentinel + await c.close() + + +async def test_execute_request_get_builds_params(transport_client: Any) -> None: + """``_execute_request`` places query params on GET requests.""" + + captured: dict[str, Any] = {} + + async def _capture(method: str, url: str, **kw: Any) -> FakeResponse: + captured.update({"method": method, "url": url, "kw": kw}) + return FakeResponse(status_code=200, payload={}) + + transport_client._send_request = _capture # type: ignore[method-assign] + await transport_client._execute_request( + method="get", + endpoint="Assistance/Ticket", + success_statuses=(200,), + params={"range": "0-49"}, + ) + assert captured["method"] == "get" + assert "Assistance/Ticket" in captured["url"] + assert "params" in captured["kw"] + + +async def test_execute_request_post_builds_json_body(transport_client: Any) -> None: + """``_execute_request`` places the body in ``json`` for non-GET verbs.""" + + captured: dict[str, Any] = {} + + async def _capture(method: str, url: str, **kw: Any) -> FakeResponse: + captured.update({"method": method, "kw": kw}) + return FakeResponse(status_code=201, payload={}) + + transport_client._send_request = _capture # type: ignore[method-assign] + await transport_client._execute_request( + method="post", + endpoint="Assistance/Ticket", + success_statuses=(201,), + json_body={"name": "t"}, + include_content_type=True, + ) + assert captured["method"] == "post" + assert captured["kw"].get("json") == {"name": "t"} + + +async def test_get_request_returns_response(transport_client: Any) -> None: + """``_get_request`` dispatches via ``_execute_request`` and returns the response.""" + + resp = await transport_client._get_request("Assistance/Ticket") + assert resp.status_code == 200 + + +async def test_post_request_returns_response(transport_client: Any) -> None: + """``_post_request`` dispatches and returns the response.""" + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=201, payload={"id": 99}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = await transport_client._post_request( + "Assistance/Ticket", json_body={"name": "t"} + ) + assert resp.status_code == 201 + + +async def test_update_request_returns_response(transport_client: Any) -> None: + """``_update_request`` dispatches and returns the response.""" + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload={}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = await transport_client._update_request( + "Assistance/Ticket/1", json_body={"name": "u"} + ) + assert resp.status_code == 200 + + +async def test_delete_request_returns_response(transport_client: Any) -> None: + """``_delete_request`` dispatches and returns the response.""" + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=204, payload={}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = await transport_client._delete_request("Assistance/Ticket/1") + assert resp.status_code == 204 + + +# --- Retry semantics ------------------------------------------------------- +# +# These tests need a client whose ``_send_request`` has *not* been +# pre-stubbed at the instance level: several of them replace +# ``_session.request`` instead, one seam lower, and rely on the real +# ``_send_request`` bound method to route through it. If ``_send_request`` +# were already overridden by ``transport_client`` above, that override would +# shadow the real method and the ``_session.request`` replacement would +# never be exercised. Hence a second, lighter fixture (``retry_client``) +# rather than reusing ``transport_client``. + + +@pytest.fixture(autouse=True) +def _no_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """Drop the 3s fixed wait so retry tests stay instant. + + The decorator's ``Retrying`` object is patched directly. Patching + ``tenacity.nap.time.sleep`` would work today but silently stops working + on the async path, so it is deliberately not used here. + """ + + for name in _RETRIED_METHODS: + monkeypatch.setattr(getattr(AsyncGlpiClient, name).retry, "wait", wait_fixed(0)) + + +@pytest.fixture +async def retry_client() -> AsyncIterator[Any]: + """Return a client with auth stubbed so no token call is made.""" + + c = make_client() + c._auth.access_token = "test-token" + + async def _ensure_token() -> None: + return None + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + yield c + await c.close() + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +async def test_5xx_is_retried_three_times_and_reraises_server_error( + retry_client: Any, method_name: str +) -> None: + """A persistent 5xx costs 3 attempts and surfaces as ``GlpiServerError``. + + Parametrized across all four retried verbs (``_get_request``, + ``_post_request``, ``_update_request``, ``_delete_request``): they share + the same decorator, but before this test only ``_get_request``'s attempt + count was pinned. + """ + + attempts: list[int] = [] + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + return FakeResponse( + status_code=500, payload={}, text="boom", reason="Server Error" + ) + + retry_client._send_request = _send # type: ignore[method-assign] + with pytest.raises(GlpiServerError) as excinfo: + await getattr(retry_client, method_name)("Assistance/Ticket") + + assert len(attempts) == 3 + assert excinfo.value.status_code == 500 + assert excinfo.value.url == "https://glpi.example.test/api.php/Assistance/Ticket" + + +async def test_persistent_5xx_does_not_surface_as_retry_error( + retry_client: Any, +) -> None: + """``reraise=True``: callers see the real error, never ``tenacity.RetryError``.""" + + import tenacity + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse( + status_code=503, payload={}, text="down", reason="Service Unavailable" + ) + + retry_client._send_request = _send # type: ignore[method-assign] + with pytest.raises(GlpiServerError) as excinfo: + await retry_client._get_request("Assistance/Ticket") + assert not isinstance(excinfo.value, tenacity.RetryError) + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +async def test_4xx_is_not_retried_by_the_transport( + retry_client: Any, method_name: str +) -> None: + """A 4xx is logged and returned by ``finalize_request_response``, not retried. + + Parametrized across all four retried verbs so a predicate regression + that starts retrying 4xx on any single verb fails loudly. + """ + + attempts: list[int] = [] + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + return FakeResponse(status_code=404, payload={}, text="nope") + + retry_client._send_request = _send # type: ignore[method-assign] + response = await getattr(retry_client, method_name)("Assistance/Ticket/1") + + assert len(attempts) == 1 + assert response.status_code == 404 + + +async def test_tolerant_search_still_returns_empty_on_4xx(retry_client: Any) -> None: + """Search endpoints that pass no ``failure_message`` still swallow a 4xx. + + Guards the 7 tolerant ``_resource_list`` call sites against the 4xx raise + being moved into ``finalize_request_response``. + """ + + async def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=400, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + assert await retry_client.search_tickets() == [] + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +async def test_network_errors_are_still_retried( + retry_client: Any, method_name: str +) -> None: + """Real transport faults are translated and still retried three times. + + The fault is injected at ``session.request`` -- *below* the translation + boundary -- rather than by stubbing ``_send_request``. That matters: a stub + above the boundary would raise the HTTP library's own exception, which the + retry predicate no longer names, so the test would pass or fail for + reasons unrelated to the behaviour it is meant to pin. Injecting here + exercises the real path end to end: a genuine ``httpx`` fault, translated + into ``GlpiTransportError``, matched by the predicate, retried three + times, and surfaced to the caller as a library error. + + Parametrized across all four retried verbs so the network-fault attempt + count is pinned for each, not just ``_get_request``. + """ + + attempts: list[int] = [] + + async def _request(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + raise httpx.ConnectError("network down") + + retry_client._session.request = _request + with pytest.raises(GlpiTransportError): + await getattr(retry_client, method_name)("Assistance/Ticket") + + assert len(attempts) == 3 + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +async def test_no_third_party_exception_reaches_the_caller( + retry_client: Any, method_name: str +) -> None: + """A network fault never surfaces as the HTTP library's own exception. + + The public contract is that ``GlpiError`` is sufficient to catch the + library's failures. This pins the half of that promise which used to be + false: transport faults escaped as third-party exceptions, forcing callers + to import the HTTP library. If the translation is ever removed, the raw + exception reaches the caller and this fails. + """ + + async def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectError("network down") + + retry_client._session.request = _request + with pytest.raises(GlpiError) as excinfo: + await getattr(retry_client, method_name)("Assistance/Ticket") + + assert not isinstance(excinfo.value, httpx.HTTPError) + # The original fault stays reachable for debugging. + assert isinstance(excinfo.value.__cause__, httpx.ConnectError) + + +async def test_timeouts_narrow_to_the_timeout_subclass(retry_client: Any) -> None: + """A timeout surfaces as ``GlpiTimeoutError``, not just the base class. + + ``GlpiTimeoutError`` exists so callers can single out the "GLPI was too + slow" case from "GLPI was unreachable". That only works if the translation + actually inspects the fault type rather than flattening everything to the + base class. + """ + + async def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectTimeout("too slow") + + retry_client._session.request = _request + with pytest.raises(GlpiTimeoutError): + await retry_client._get_request("Assistance/Ticket") diff --git a/glpi_python_client/_async/clients/custom/__init__.py b/glpi_python_client/_async/clients/custom/__init__.py index a0d0e90..1a89216 100644 --- a/glpi_python_client/_async/clients/custom/__init__.py +++ b/glpi_python_client/_async/clients/custom/__init__.py @@ -8,8 +8,8 @@ Each helper is written once. The fan-out points call ``gather`` from :mod:`glpi_python_client._async._concurrency`, which runs them -concurrently here and sequentially in the generated tree -- so there is -no second copy of this logic to keep in step. +concurrently on the async surface and sequentially on the generated one +-- so there is no second copy of this logic to keep in step. """ from __future__ import annotations diff --git a/glpi_python_client/_async/clients/custom/tests/__init__.py b/glpi_python_client/_async/clients/custom/tests/__init__.py new file mode 100644 index 0000000..58fbdf4 --- /dev/null +++ b/glpi_python_client/_async/clients/custom/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the aggregating helpers built on the API mixins.""" diff --git a/glpi_python_client/_async/clients/custom/tests/test_statistics.py b/glpi_python_client/_async/clients/custom/tests/test_statistics.py new file mode 100644 index 0000000..46fdc79 --- /dev/null +++ b/glpi_python_client/_async/clients/custom/tests/test_statistics.py @@ -0,0 +1,786 @@ +"""Unit tests for the statistics helpers. + +The tests stub the API methods on a real client so the statistics +aggregations exercise their real summarization logic without any network +call. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any + +import pytest + +from glpi_python_client import ( + GlpiPriority, + GlpiTicketStatus, + GlpiTicketType, + GlpiValidationError, +) +from glpi_python_client._async.clients.custom._statistics import ( + _V1_SO_ASSIGNEE, + _V1_SO_REQUESTER, +) +from glpi_python_client.models.api_schema._common import ( + IdNameCompletenameRef, + IdNameRef, +) +from glpi_python_client.models.api_schema.assistance._ticket import GetTicket +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + GetTicketTask, +) + + +class _FakeV1Search: + """Stand-in v1 session answering ``search/Ticket`` actor lookups. + + Maps a v1 searchOption id to the ticket ids that option should match, + and mimics the wire shape the real endpoint returns: rows keyed by the + searchOption id as a *string*, plus a ``totalcount`` used to bound + paging (the live API answers HTTP 400 for a range past the end). + """ + + def __init__(self, by_option: dict[int, list[int]]) -> None: + self.by_option = by_option + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append({"method": method, "path": path, "params": params}) + criteria = params or {} + matched: list[int] = [] + index = 0 + while f"criteria[{index}][field]" in criteria: + option = int(str(criteria[f"criteria[{index}][field]"])) + matched.extend(self.by_option.get(option, [])) + index += 1 + rows = [{"2": ticket_id} for ticket_id in sorted(set(matched))] + return {"totalcount": len(rows), "data": rows} + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _ticket( + *, + entity_id: int | None = 1, + status_id: int | None = GlpiTicketStatus.NEW.value, + priority: int | None = GlpiPriority.MEDIUM.value, + ticket_type: int | None = GlpiTicketType.INCIDENT.value, +) -> GetTicket: + """Build one validated ticket model with the requested aggregation keys.""" + + payload: dict[str, Any] = { + "id": 1, + "name": "demo", + "content": "x
", + } + if entity_id is not None: + payload["entity"] = IdNameCompletenameRef( + id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}" + ) + if status_id is not None: + payload["status"] = IdNameRef(id=status_id, name=f"s{status_id}") + if priority is not None: + payload["priority"] = priority + if ticket_type is not None: + payload["type"] = ticket_type + return GetTicket(**payload) + + +async def test_get_ticket_statistics_aggregates_by_entity_status_priority_type( + client: Any, +) -> None: + """All aggregation buckets are produced from the search response.""" + + captured: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [ + _ticket(), + _ticket( + entity_id=1, + status_id=GlpiTicketStatus.SOLVED.value, + priority=GlpiPriority.HIGH.value, + ticket_type=GlpiTicketType.REQUEST.value, + ), + _ticket( + entity_id=2, + status_id=GlpiTicketStatus.NEW.value, + priority=GlpiPriority.LOW.value, + ticket_type=None, + ), + _ticket(entity_id=None, status_id=None, priority=None, ticket_type=None), + ] + + client.search_tickets = fake_search # type: ignore[method-assign] + result = await client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + extra_filter="status==1", + ) + + assert "date_creation=ge=2026-01-01" in captured["filter"] + assert "status==1" in captured["filter"] + + entities = result["entities"] + assert "1" in entities and "2" in entities and "unknown" in entities + assert entities["1"]["total"] == 2 + assert entities["1"]["by_priority"] == {"MEDIUM": 1, "HIGH": 1} + assert entities["1"]["by_type"] == {"INCIDENT": 1, "REQUEST": 1} + # Unknown entity bucket falls back to UNKNOWN labels for missing fields. + assert entities["unknown"]["by_type"] == {"UNKNOWN": 1} + assert entities["unknown"]["by_priority"] == {"UNKNOWN": 1} + assert entities["unknown"]["by_status"] == {"UNKNOWN": 1} + + +async def test_get_ticket_statistics_default_window_uses_today( + client: Any, +) -> None: + """When no dates are passed the helper uses today minus default_days.""" + + captured: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + await client.get_ticket_statistics(default_days=7) + end = date.today() + start = end - timedelta(days=6) + assert f"date_creation=ge={start.isoformat()}" in captured["filter"] + assert f"date_creation=le={end.isoformat()} 23:59:59" in captured["filter"] + + +async def test_get_ticket_statistics_rejects_invalid_window(client: Any) -> None: + """Invalid date inputs raise locally before any HTTP request. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="default_days") as exc1: + await client.get_ticket_statistics(default_days=0) + assert isinstance(exc1.value, ValueError) + with pytest.raises(GlpiValidationError, match="start_date") as exc2: + await client.get_ticket_statistics( + start_date="2026-02-01", end_date="2026-01-01" + ) + assert isinstance(exc2.value, ValueError) + + +async def test_get_ticket_statistics_rejects_malformed_iso_date( + client: Any, +) -> None: + """A malformed ISO date string raises ``GlpiValidationError``, not a bare + ``date.fromisoformat`` ``ValueError``. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working, and the original ``ValueError`` + from ``date.fromisoformat`` is chained via ``from`` rather than + swallowed. + """ + + with pytest.raises(GlpiValidationError, match="start_date") as excinfo: + await client.get_ticket_statistics( + start_date="2026-13-45", end_date="2026-01-31" + ) + assert isinstance(excinfo.value, ValueError) + assert isinstance(excinfo.value.__cause__, ValueError) + + +async def test_get_task_statistics_zero_for_empty_input(client: Any) -> None: + """An empty ticket list returns zeroed totals without any HTTP call.""" + + result = await client.get_task_statistics([]) + assert result == { + "ticket_count": 0, + "task_count": 0, + "total_duration": 0, + "duration_by_user": {}, + "duration_by_ticket": {}, + } + + +async def test_get_task_statistics_aggregates_by_user_and_ticket( + client: Any, +) -> None: + """Durations group by user and parent ticket.""" + + async def fake_list(ticket_id: int) -> list[GetTicketTask]: + if ticket_id == 1: + return [ + GetTicketTask( + id=10, + tickets_id=1, + duration=600, + user=IdNameRef(id=42, name="alice"), + ), + GetTicketTask( + id=11, + tickets_id=1, + duration=300, + user=None, + ), + ] + return [ + GetTicketTask( + id=20, + tickets_id=None, + duration=None, + user=IdNameRef(id=42, name="alice"), + ), + ] + + client.list_ticket_tasks = fake_list # type: ignore[method-assign] + result = await client.get_task_statistics([1, 2]) + assert result["ticket_count"] == 2 + assert result["task_count"] == 3 + assert result["total_duration"] == 900 + assert result["duration_by_user"] == {"42": 600, "unknown": 300} + assert result["duration_by_ticket"] == {1: 900} + + +# --------------------------------------------------------------------------- +# get_ticket_statistics — extended filters (Change 2) +# --------------------------------------------------------------------------- + + +async def test_get_ticket_statistics_entity_id_filter(client: Any) -> None: + """When entity_id is given its RSQL clause is appended to the filter.""" + + captured: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + await client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_id=7, + ) + # v2 types Ticket.entity as object{id,name}; the bare `entities_id` is a + # v1 field name that v2 silently ignores, returning every ticket. + assert "entity.id==7" in captured["filter"] + assert "entities_id" not in captured["filter"] + # v2 counts soft-deleted tickets unless told otherwise. + assert "is_deleted==false" in captured["filter"] + + +async def test_get_ticket_statistics_entity_name_resolution(client: Any) -> None: + """When entity_name is given entities are resolved and IDs ORed.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + captured_tickets: dict[str, Any] = {} + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int | None = 200, start: int = 0 + ) -> list[GetEntity]: + return [GetEntity(id=3, name="Acme"), GetEntity(id=4, name="Acme Sub")] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + await client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="Acme", + ) + assert "entity.id==3" in captured_tickets["filter"] + assert "entity.id==4" in captured_tickets["filter"] + assert "entities_id" not in captured_tickets["filter"] + # The OR group must be parenthesised or the date window stops applying + # to every entity after the first. + assert "(entity.id==3,entity.id==4)" in captured_tickets["filter"] + + +async def test_get_ticket_statistics_entity_name_no_match(client: Any) -> None: + """When entity_name matches nothing the fast-path returns empty entities.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int | None = 200, start: int = 0 + ) -> list[GetEntity]: + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + result = await client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="NonExistent", + ) + assert result == {"entities": {}} + + +async def test_get_ticket_statistics_extra_filter_appended(client: Any) -> None: + """extra_filter is AND-joined with the date window.""" + + captured: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + await client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + extra_filter="priority==5", + ) + assert "date_creation=ge=2026-01-01" in captured["filter"] + assert "priority==5" in captured["filter"] + + +async def test_get_ticket_statistics_default_days_window(client: Any) -> None: + """default_days shifts the start of the window without other params.""" + + from datetime import date, timedelta + + captured: dict[str, Any] = {} + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + await client.get_ticket_statistics(default_days=14) + end = date.today() + start = end - timedelta(days=13) + assert f"date_creation=ge={start.isoformat()}" in captured["filter"] + assert f"date_creation=le={end.isoformat()} 23:59:59" in captured["filter"] + + +# --------------------------------------------------------------------------- +# get_task_durations (Change 3) +# --------------------------------------------------------------------------- + + +def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket: + """Build a minimal GetTicket for task-duration tests.""" + + payload: dict[str, Any] = {"id": ticket_id, "name": f"t{ticket_id}", "content": "c"} + if entity_id is not None: + payload["entity"] = IdNameCompletenameRef( + id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}" + ) + return GetTicket(**payload) + + +async def test_get_task_durations_empty_ticket_list(client: Any) -> None: + """When no tickets match, all durations are zero.""" + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + for batch in []: + yield batch + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + result = await client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) + assert result["total_duration"] == 0 + assert result["task_count"] == 0 + assert result["duration_by_entity"] == {} + assert result["tasks"] is None + + +async def test_get_task_durations_entity_grouping(client: Any) -> None: + """duration_by_entity groups ticket-task durations by entity key.""" + + tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)] + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield tickets + + async def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 2, + "task_count": 2, + "total_duration": 1200, + "duration_by_user": {"42": 1200}, + "duration_by_ticket": {1: 600, 2: 600}, + } + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_statistics = fake_task_stats # type: ignore[method-assign] + result = await client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) + assert result["duration_by_entity"] == {"10": 600, "20": 600} + + +async def test_get_task_durations_return_task_details_true(client: Any) -> None: + """When return_task_details=True a tasks list with correct shape is returned.""" + + tickets = [_make_ticket(1, entity_id=10)] + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield tickets + + async def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 1, + "task_count": 1, + "total_duration": 300, + "duration_by_user": {"7": 300}, + "duration_by_ticket": {1: 300}, + } + + async def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]: + return [ + GetTicketTask( + id=99, + tickets_id=ticket_id, + duration=300, + user=IdNameRef(id=7, name="alice"), + ) + ] + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_statistics = fake_task_stats # type: ignore[method-assign] + client.list_ticket_tasks = fake_list_tasks # type: ignore[method-assign] + result = await client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31", return_task_details=True + ) + assert isinstance(result["tasks"], list) + assert len(result["tasks"]) == 1 + task = result["tasks"][0] + assert task["task_id"] == 99 + assert task["ticket_id"] == 1 + assert task["duration"] == 300 + assert task["user_id"] == 7 + + +async def test_get_task_durations_return_task_details_false(client: Any) -> None: + """When return_task_details=False the tasks key is None.""" + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + for batch in []: + yield batch + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + result = await client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) + assert result["tasks"] is None + + +# --------------------------------------------------------------------------- +# get_user_activity (Change 4) +# --------------------------------------------------------------------------- + + +async def test_get_user_activity_raises_without_identifier(client: Any) -> None: + """Calling without any identifier raises ``GlpiValidationError``. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="user_id") as excinfo: + await client.get_user_activity() + assert isinstance(excinfo.value, ValueError) + + +async def test_get_user_activity_single_user_happy_path(client: Any) -> None: + """A single matched user populates all activity keys.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [GetUser(id=42, username="alice", firstname="Alice", realname="Smith")] + + window_calls: list[str] = [] + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + # One date-window walk now serves every user and every role; the + # per-role split comes from the v1 id sets intersected below. + window_calls.append(rsql_filter) + yield [_make_ticket(1), _make_ticket(2)] + + # Ticket 1 is assigned to the user, ticket 2 is not; ticket 3 is + # assigned but falls outside the window, so it must not be counted. + client._v1 = _FakeV1Search( # type: ignore[assignment] + {_V1_SO_ASSIGNEE: [1, 3], _V1_SO_REQUESTER: []} + ) + + async def fake_task_durations( + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + user_id: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + return { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 600, + "task_count": 1, + "duration_by_user": {"42": 600}, + "duration_by_entity": {"10": 600}, + } + + client.search_users = fake_search_users # type: ignore[method-assign] + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_durations = fake_task_durations # type: ignore[method-assign] + + result = await client.get_user_activity( + username="alice", start_date="2026-01-01", end_date="2026-01-31" + ) + users = result["users"] + assert len(users) == 1 + key = next(iter(users)) + data = users[key] + assert data["user_ids"] == [42] + # Ticket 1 is both in the window and assigned; ticket 3 is assigned but + # outside the window, so the intersection keeps exactly one. + assert data["tickets_as_technician"] == 1 + assert data["tickets_as_recipient"] == 0 + assert "total_duration" in data["task_durations"] + # The window is walked once, not once per user per role, and it must + # exclude trashed tickets. + assert len(window_calls) == 1 + assert "is_deleted==false" in window_calls[0] + # A regression to the v1 field names would be invisible against the + # live server, so pin their absence here. + assert "users_id_assign" not in window_calls[0] + assert "users_id_requester" not in window_calls[0] + + +async def test_get_user_activity_raises_when_no_users_matched(client: Any) -> None: + """When search_users returns empty a ``GlpiValidationError`` is raised. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [] + + client.search_users = fake_search_users # type: ignore[method-assign] + with pytest.raises(GlpiValidationError, match="No users matched") as excinfo: + await client.get_user_activity(username="ghost") + assert isinstance(excinfo.value, ValueError) + + +async def test_get_user_activity_multi_user_merge(client: Any) -> None: + """Multiple users under the same display key have their counts merged.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + async def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [ + GetUser(id=1, username="a1", firstname="Alice", realname="Smith"), + GetUser(id=2, username="a2", firstname="Alice", realname="Smith"), + ] + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield [_make_ticket(1)] + + # Both merged users are assigned ticket 1, so each contributes 1. + client._v1 = _FakeV1Search( # type: ignore[assignment] + {_V1_SO_ASSIGNEE: [1], _V1_SO_REQUESTER: []} + ) + + async def fake_task_durations( + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + user_id: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + return { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 300, + "task_count": 1, + "duration_by_user": {str(user_id): 300}, + "duration_by_entity": {"10": 300}, + } + + client.search_users = fake_search_users # type: ignore[method-assign] + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_durations = fake_task_durations # type: ignore[method-assign] + + result = await client.get_user_activity( + realname="Smith", start_date="2026-01-01", end_date="2026-01-31" + ) + users = result["users"] + # Both users share the same display key → merged into one entry + assert len(users) == 1 + key = next(iter(users)) + data = users[key] + assert sorted(data["user_ids"]) == [1, 2] + assert data["tickets_as_technician"] == 2 # 1 per user + assert data["task_durations"]["total_duration"] == 600 # 300 per user + + +class _FakeV1Tasks: + """v1 stand-in serving pages of raw ``TicketTask`` collection rows.""" + + def __init__(self, rows: list[dict[str, Any]], page_size: int = 1000) -> None: + self.rows = rows + self.page_size = page_size + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append({"path": path, "params": params}) + rng = str((params or {}).get("range", "0-999")) + start = int(rng.split("-")[0]) + return self.rows[start : start + self.page_size] + + async def close(self) -> None: + """No-op.""" + + +def _task_row( + task_id: int, ticket_id: int, duration: int, author: int +) -> dict[str, Any]: + return { + "id": task_id, + "tickets_id": ticket_id, + "actiontime": duration, + "users_id": author, + "users_id_tech": 999, + "date_creation": "2026-07-10 09:00:00", + } + + +async def test_v1_task_statistics_matches_the_per_ticket_shape(client: Any) -> None: + """The bulk v1 sweep produces the same aggregate as the per-ticket path. + + v1 ``actiontime`` is v2 ``duration`` and v1 ``users_id`` is the v2 task + ``user``; both were confirmed equal against a live GLPI 11 instance, so + the optimisation must not change any returned value. + """ + + rows = [ + _task_row(1, 10, 600, 5), + _task_row(2, 10, 300, 5), + _task_row(3, 11, 900, 7), + # Belongs to a ticket outside the requested set and must be ignored. + _task_row(4, 99, 12345, 5), + ] + client._v1 = _FakeV1Tasks(rows) # type: ignore[assignment] + + result = await client._v1_task_statistics([10, 11], since=date(2026, 7, 1)) + + assert result["ticket_count"] == 2 + assert result["task_count"] == 3 + assert result["total_duration"] == 1800 + assert result["duration_by_ticket"] == {10: 900, 11: 900} + assert result["duration_by_user"] == {"5": 900, "7": 900} + + +async def test_v1_task_statistics_stops_paging_before_the_window( + client: Any, +) -> None: + """Paging stops once a page predates the window start. + + A task cannot exist before its ticket, so nothing relevant is skipped. + """ + + old = _task_row(5, 10, 60, 5) | {"date_creation": "2020-01-01 09:00:00"} + fake = _FakeV1Tasks([_task_row(1, 10, 600, 5), old], page_size=2) + client._v1 = fake # type: ignore[assignment] + + result = await client._v1_task_statistics([10], since=date(2026, 7, 1)) + + # Both rows are in the first page, so they are both aggregated; the + # sweep then stops rather than requesting another page. + assert result["task_count"] == 2 + assert len(fake.calls) == 1 + assert fake.calls[0]["path"] == "TicketTask" + assert fake.calls[0]["params"]["sort"] == "date_creation" + assert fake.calls[0]["params"]["order"] == "DESC" + + +async def test_get_task_durations_uses_per_ticket_path_below_threshold( + client: Any, +) -> None: + """A small ticket set keeps the v2 per-ticket path and needs no v1.""" + + async def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield [_make_ticket(1)] + + calls: list[list[int]] = [] + + async def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: + calls.append(ticket_ids) + return { + "ticket_count": len(ticket_ids), + "task_count": 0, + "total_duration": 0, + "duration_by_user": {}, + "duration_by_ticket": {}, + } + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_statistics = fake_task_stats # type: ignore[method-assign] + # No v1 session configured at all: the small-set path must not need one. + result = await client.get_task_durations( + start_date="2026-07-01", end_date="2026-07-31" + ) + + assert result["task_count"] == 0 + assert calls == [[1]] diff --git a/glpi_python_client/_async/clients/custom/tests/test_ticket_context.py b/glpi_python_client/_async/clients/custom/tests/test_ticket_context.py new file mode 100644 index 0000000..446b053 --- /dev/null +++ b/glpi_python_client/_async/clients/custom/tests/test_ticket_context.py @@ -0,0 +1,58 @@ +"""Unit tests for the ticket-context mixin. + +The tests stub ``_get_request`` on a real client so +:meth:`~glpi_python_client._async.clients.custom._ticket_context.TicketContextMixin.get_ticket_context` +exercises its real aggregation logic without any network call. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client._async._testing import make_client +from glpi_python_client.testing.utils import FakeResponse + + +async def test_get_ticket_context_assembles_ticket_and_timeline() -> None: + """``get_ticket_context`` fetches one ticket and four timeline lists. + + The method must call ``get_ticket`` once and one list helper for each + timeline resource (tasks, followups, solutions, documents), then + bundle all results into a single :class:`GlpiTicketContext`. + """ + + client = make_client() + calls: list[str] = [] + + async def _get( + endpoint: str, + params: Any = None, + skip_entity: bool = False, + ) -> FakeResponse: + calls.append(endpoint) + if endpoint.endswith("/Timeline/Task"): + return FakeResponse(status_code=200, payload=[]) + if endpoint.endswith("/Timeline/Followup"): + return FakeResponse(status_code=200, payload=[]) + if endpoint.endswith("/Timeline/Solution"): + return FakeResponse(status_code=200, payload=[]) + if endpoint.endswith("/Timeline/Document"): + return FakeResponse(status_code=200, payload=[]) + # Primary ticket fetch. + return FakeResponse( + status_code=200, + payload={"id": 42, "name": "Test ticket", "content": "body
"}, + ) + + client._get_request = _get # type: ignore[method-assign] + ctx = await client.get_ticket_context(42) + + assert ctx.ticket.id == 42 + assert ctx.tasks == [] + assert ctx.followups == [] + assert ctx.solutions == [] + assert ctx.documents == [] + # Five separate GET calls must have been dispatched. + assert len(calls) == 5 + + await client.close() diff --git a/glpi_python_client/_async/clients/tests/__init__.py b/glpi_python_client/_async/clients/tests/__init__.py new file mode 100644 index 0000000..aeb0b4a --- /dev/null +++ b/glpi_python_client/_async/clients/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for client construction, configuration and lifecycle.""" diff --git a/glpi_python_client/_async/clients/tests/test_client.py b/glpi_python_client/_async/clients/tests/test_client.py new file mode 100644 index 0000000..99ff47c --- /dev/null +++ b/glpi_python_client/_async/clients/tests/test_client.py @@ -0,0 +1,165 @@ +"""Unit tests for client construction and lifecycle helpers.""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +from glpi_python_client import AsyncGlpiClient +from glpi_python_client._async._testing import make_client +from glpi_python_client._async.clients.commons._config import build_client_env_config + + +async def test_glpi_client_from_env_uses_overrides_and_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from_env`` resolves env vars and applies overrides.""" + + env = { + "GLPI_API_URL": "https://glpi.example.test/api.php/v2", + "GLPI_USERNAME": "u", + "GLPI_PASSWORD": "p", + } + client = AsyncGlpiClient.from_env(env=env) + try: + assert client.glpi_api_url.endswith("/api.php/v2") + finally: + await client.close() + + +async def test_glpi_client_close_is_idempotent() -> None: + """Calling ``close`` twice does not raise.""" + + client = AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + ) + await client.close() + await client.close() + + +async def test_glpi_client_async_context_manager() -> None: + """The context manager closes the client on exit.""" + + async with make_client() as c: + assert c._session is not None + assert c._closed is True + # Closing must be observable, not just recorded on a private flag: + # every transport helper goes through _ensure_open, so the first one + # reached after the block has to refuse. Match the guard's own message, + # not the bare word "closed" -- with the guard disabled the call still + # raises RuntimeError, but from httpx ("Cannot send a request, as the + # client has been closed."), so the looser pattern passes either way. + with pytest.raises(RuntimeError, match="GLPI client is closed"): + await c._ensure_token() + + +async def test_glpi_client_rejects_invalid_credentials() -> None: + """Constructor refuses to build a client with no usable credentials.""" + + with pytest.raises(ValueError): + AsyncGlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2") + + +async def test_glpi_client_v1_session_built_when_configured() -> None: + """Providing v1_base_url + v1_user_token instantiates the v1 session.""" + + client = AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + v1_base_url="https://glpi.example.test/apirest.php", + v1_user_token="user-token", + v1_app_token="app-token", + ) + try: + assert client._v1 is not None + finally: + await client.close() + + +async def test_glpi_client_rejects_partial_v1_config() -> None: + """Half-configured v1 values raise at construction time.""" + + with pytest.raises(ValueError, match="v1_base_url and v1_user_token"): + AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + v1_base_url="https://glpi.example.test/apirest.php", + ) + + +async def test_environ_default_is_used_when_env_argument_omitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no env mapping is provided ``os.environ`` is used.""" + + monkeypatch.setenv("GLPI_API_URL", "https://from-environ.example/api.php/v2") + monkeypatch.setenv("GLPI_USERNAME", "u") + monkeypatch.setenv("GLPI_PASSWORD", "p") + client = AsyncGlpiClient.from_env() + try: + assert client.glpi_api_url.endswith("/api.php/v2") + finally: + await client.close() + + +async def test_async_transport_ensure_open_blocks_after_close() -> None: + """Closed clients raise on subsequent transport calls.""" + + client = AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + ) + await client.close() + with pytest.raises(RuntimeError, match="closed"): + client._ensure_open() + + +async def test_glpi_client_init_failure_creates_no_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bad credential set is rejected before any session is constructed. + + This used to build the session first and unwind it from an ``except`` + clause. That assumes a constructor can always close a partially built + session before it returns, which is not something every client variant + guarantees -- so the configuration is now validated up front instead. + + Asserting *nothing was built* is the stronger property: there is no + window in which a session exists but the client does not, so there is + nothing that can leak if the unwind is ever missed. + """ + + import httpx + + constructed: list[object] = [] + original_init = httpx.AsyncClient.__init__ + + def _track_init(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> None: + constructed.append(self) + original_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.AsyncClient, "__init__", _track_init) + with pytest.raises(ValueError): + AsyncGlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + client_id="only-id-no-secret", + ) + assert constructed == [], "a transport session was built for a rejected config" + + +async def test_no_other_vars_leak_into_environ_test() -> None: + """Sanity check that environment unset values stay None.""" + + config = build_client_env_config( + prefix="GLPI_", + env={k: v for k, v in os.environ.items() if not k.startswith("GLPI_")}, + overrides={}, + ) + assert config["glpi_api_url"] is None diff --git a/glpi_python_client/_async/conftest.py b/glpi_python_client/_async/conftest.py new file mode 100644 index 0000000..93fd730 --- /dev/null +++ b/glpi_python_client/_async/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures shared by every unit test in this tree. + +Generated into the sync tree alongside the modules it serves, so both +surfaces get a fixture that builds the client they actually test. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest + +from glpi_python_client import AsyncGlpiClient +from glpi_python_client._async._testing import make_client + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncGlpiClient]: + """Yield a default in-memory client and close it afterwards.""" + + instance = make_client() + yield instance + await instance.close() diff --git a/glpi_python_client/_async/tests/__init__.py b/glpi_python_client/_async/tests/__init__.py new file mode 100644 index 0000000..7fc9482 --- /dev/null +++ b/glpi_python_client/_async/tests/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the hand-written concurrency primitives. + +``_concurrency`` is the one module maintained by hand on both sides, so +its tests are too: each surface asserts what is true of its own +primitives rather than a transformed copy of the other's. +""" diff --git a/glpi_python_client/tests/test_async_surface.py b/glpi_python_client/_async/tests/test_concurrency.py similarity index 96% rename from glpi_python_client/tests/test_async_surface.py rename to glpi_python_client/_async/tests/test_concurrency.py index 04f65ed..4f4d134 100644 --- a/glpi_python_client/tests/test_async_surface.py +++ b/glpi_python_client/_async/tests/test_concurrency.py @@ -23,7 +23,7 @@ from glpi_python_client import AsyncGlpiClient, GlpiTimeoutError, GlpiTransportError from glpi_python_client._async._concurrency import gather -from glpi_python_client.testing.utils import make_async_client +from glpi_python_client._async._testing import make_client class _Response: @@ -60,7 +60,7 @@ async def _request(method: str, url: str, **kwargs: Any) -> _Response: async def test_a_read_awaits_and_returns_a_model() -> None: """The async client dispatches and parses exactly like its twin.""" - client = make_async_client() + client = make_client() calls = _stub(client, {"id": 42, "name": "async ticket"}) try: ticket = await client.get_ticket(42) @@ -74,7 +74,7 @@ async def test_a_read_awaits_and_returns_a_model() -> None: async def test_close_is_idempotent_and_blocks_further_calls() -> None: """Closing twice is safe and a closed client refuses to dispatch.""" - client = make_async_client() + client = make_client() _stub(client, {"id": 1, "name": "x"}) await client.close() await client.close() @@ -85,7 +85,7 @@ async def test_close_is_idempotent_and_blocks_further_calls() -> None: async def test_the_async_context_manager_closes_on_exit() -> None: """``async with`` releases the client, so ``__aexit__`` really awaits.""" - client = make_async_client() + client = make_client() _stub(client, {"id": 1, "name": "x"}) async with client as entered: assert entered is client @@ -103,7 +103,7 @@ async def test_concurrent_tasks_contend_the_auth_lock_without_deadlocking() -> N is the only way to observe that. """ - client = make_async_client() + client = make_client() calls = _stub(client, {"id": 1, "name": "x"}) # Force every task through the token-acquisition path. client._auth.access_token = None @@ -176,7 +176,7 @@ async def test_network_faults_are_translated_on_the_async_path() -> None: expression is a distinct mistake the sync test cannot detect. """ - client = make_async_client() + client = make_client() _stub(client, {}) async def _boom(method: str, url: str, **kwargs: Any) -> _Response: @@ -196,7 +196,7 @@ async def _boom(method: str, url: str, **kwargs: Any) -> _Response: async def test_timeouts_narrow_on_the_async_path() -> None: """A timeout narrows to ``GlpiTimeoutError`` when awaited too.""" - client = make_async_client() + client = make_client() _stub(client, {}) async def _slow(method: str, url: str, **kwargs: Any) -> _Response: @@ -219,7 +219,7 @@ async def test_the_paginating_generator_is_an_async_generator() -> None: in either shows up only when the generator is actually driven. """ - client = make_async_client() + client = make_client() _stub(client, [{"id": 1, "name": "one"}]) try: pages = [] diff --git a/glpi_python_client/_sync/_testing.py b/glpi_python_client/_sync/_testing.py new file mode 100644 index 0000000..8b35366 --- /dev/null +++ b/glpi_python_client/_sync/_testing.py @@ -0,0 +1,146 @@ +"""In-memory client factory and transport stubs for this tree's unit tests. + +One of a generated pair: the module is written once and +``unasync_build.py`` transforms it into its twin in the other tree. +``AsyncGlpiClient`` is already a substitution key, so each copy returns +the client of the tree it lands in while the factory stays spelled +``make_client`` on both sides -- call sites read identically wherever +they are. The same holds for the two recorder classes: each replaces a +client's four transport helpers with stubs that capture the call and hand +back a fixed response, so a mixin test can assert the endpoint, verb and +serialised body without any HTTP plumbing. + +It lives beside the tree rather than in ``glpi_python_client.testing`` +because that module is a published downstream helper whose factories must +keep their current names; this one has to change its return type when the +tree is transformed. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import GlpiClient +from glpi_python_client.testing.utils import DEFAULT_CLIENT_CONFIG, FakeResponse + +__all__ = ["FailingTransportRecorder", "TransportRecorder", "make_client"] + + +def make_client(**overrides: object) -> GlpiClient: + """Return a test client with no real HTTP plumbing. + + Any constructor keyword can be overridden while the rest of the shared + base configuration is reused. + """ + + config = dict(DEFAULT_CLIENT_CONFIG) + config.update(overrides) + return GlpiClient(**config) # type: ignore[arg-type] + + +class TransportRecorder: + """Capture the four transport calls and answer with fixed responses. + + Installed over the client's request helpers so a mixin test can assert + the endpoint, verb and serialised body without any HTTP plumbing. + """ + + def __init__( + self, + *, + get_payload: Any = None, + get_status: int = 200, + get_content: bytes | None = None, + post_payload: Any = None, + post_status: int = 201, + patch_status: int = 204, + delete_status: int = 204, + ) -> None: + self.calls: list[dict[str, Any]] = [] + self._get_payload = get_payload if get_payload is not None else [] + self._get_status = get_status + self._get_content = get_content + self._post_payload = post_payload if post_payload is not None else {"id": 999} + self._post_status = post_status + self._patch_status = patch_status + self._delete_status = delete_status + + def install(self, client: GlpiClient) -> None: + """Replace the four transport helpers with capturing stubs.""" + + def _get( + endpoint: str, + params: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "GET", + "endpoint": endpoint, + "params": params, + "skip_entity": skip_entity, + } + ) + return FakeResponse( + status_code=self._get_status, + payload=self._get_payload, + content=self._get_content, + ) + + def _post( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "POST", + "endpoint": endpoint, + "json": json_body, + "skip_entity": skip_entity, + } + ) + return FakeResponse( + status_code=self._post_status, payload=self._post_payload + ) + + def _patch( + endpoint: str, json_body: dict[str, Any] | None = None + ) -> FakeResponse: + self.calls.append( + {"method": "PATCH", "endpoint": endpoint, "json": json_body} + ) + return FakeResponse(status_code=self._patch_status, payload={}) + + def _delete( + endpoint: str, + json_body: dict[str, Any] | None = None, + skip_entity: bool = False, + ) -> FakeResponse: + self.calls.append( + { + "method": "DELETE", + "endpoint": endpoint, + "json": json_body, + "skip_entity": skip_entity, + } + ) + return FakeResponse(status_code=self._delete_status, payload={}) + + client._get_request = _get # type: ignore[method-assign, assignment] + client._post_request = _post # type: ignore[method-assign, assignment] + client._update_request = _patch # type: ignore[method-assign, assignment] + client._delete_request = _delete # type: ignore[method-assign, assignment] + + +class FailingTransportRecorder(TransportRecorder): + """Answer every verb with one fixed non-success status.""" + + def __init__(self, status: int) -> None: + super().__init__( + get_status=status, + get_payload={"err": "x"}, + post_status=status, + patch_status=status, + delete_status=status, + ) diff --git a/glpi_python_client/_sync/auth/auth.py b/glpi_python_client/_sync/auth/auth.py index b3eb0e6..4409650 100644 --- a/glpi_python_client/_sync/auth/auth.py +++ b/glpi_python_client/_sync/auth/auth.py @@ -108,8 +108,9 @@ class GLPITokenManager: password : str | None, optional Password for the password grant flow. Provide it together with ``username``. - session : httpx.AsyncClient | None, optional - Existing HTTP client to reuse. + session : httpx.Client | httpx.AsyncClient | None, optional + Existing HTTP client to reuse. Each surface takes the httpx client + that matches it; the signature names the one in force here. auth_token_refresh : int | None, optional Maximum token age in seconds before a refresh is attempted. ``None`` disables interval-based refreshes. diff --git a/glpi_python_client/_sync/auth/tests/__init__.py b/glpi_python_client/_sync/auth/tests/__init__.py new file mode 100644 index 0000000..e1ca9b8 --- /dev/null +++ b/glpi_python_client/_sync/auth/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for OAuth token management and the legacy v1 session.""" diff --git a/glpi_python_client/tests/auth/test_auth.py b/glpi_python_client/_sync/auth/tests/test_auth.py similarity index 98% rename from glpi_python_client/tests/auth/test_auth.py rename to glpi_python_client/_sync/auth/tests/test_auth.py index 8bed08b..f3c73f8 100644 --- a/glpi_python_client/tests/auth/test_auth.py +++ b/glpi_python_client/_sync/auth/tests/test_auth.py @@ -372,7 +372,9 @@ class _FailingSession: def __init__(self) -> None: self.calls: list[dict[str, object]] = [] - def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse: + def post( + self, url: str, data: dict[str, str], timeout: int + ) -> FakeResponse: self.calls.append({"url": url, "data": data, "timeout": timeout}) raise httpx.ConnectError("network down") @@ -420,7 +422,9 @@ class _FailingSession: def __init__(self) -> None: self.calls: list[dict[str, object]] = [] - def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse: + def post( + self, url: str, data: dict[str, str], timeout: int + ) -> FakeResponse: self.calls.append({"url": url, "data": data, "timeout": timeout}) raise httpx.ConnectError("network down") diff --git a/glpi_python_client/tests/auth/test_v1_session.py b/glpi_python_client/_sync/auth/tests/test_v1_session.py similarity index 95% rename from glpi_python_client/tests/auth/test_v1_session.py rename to glpi_python_client/_sync/auth/tests/test_v1_session.py index f5de54b..e46ec27 100644 --- a/glpi_python_client/tests/auth/test_v1_session.py +++ b/glpi_python_client/_sync/auth/tests/test_v1_session.py @@ -7,6 +7,7 @@ import httpx import pytest +from tenacity import wait_fixed from glpi_python_client import ( GlpiProtocolError, @@ -17,16 +18,29 @@ from glpi_python_client._sync.auth._v1_session import GLPIV1Session from glpi_python_client.testing.utils import FakeResponse +#: Every ``GLPIV1Session`` method carrying the shared network retry decorator. +_RETRIED_METHODS = ( + "_init_session", + "request_json", + "upload_document", +) + @pytest.fixture(autouse=True) def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: - """Disable tenacity wait between retry attempts to keep tests fast.""" + """Drop the 3s fixed wait so retry tests stay instant. - monkeypatch.setattr("tenacity.nap.time.sleep", lambda _seconds: None) + Each decorated method's own ``Retrying`` object is patched directly. + Patching ``tenacity.nap.time.sleep`` would work today but silently stops + working on the async path, so it is deliberately not used here. + """ + + for name in _RETRIED_METHODS: + monkeypatch.setattr(getattr(GLPIV1Session, name).retry, "wait", wait_fixed(0)) class _FakeV1Http: - """In-memory ``httpx.Client`` stand-in capturing every call.""" + """In-memory HTTP client stand-in capturing every call.""" def __init__(self, responses: dict[str, list[FakeResponse]]) -> None: self._responses = responses @@ -425,7 +439,9 @@ def test_request_json_supports_get_with_params() -> None: } ) session = _make(http) - out = session.request_json("GET", "PluginFieldsContainer", params={"range": "0-1"}) + out = session.request_json( + "GET", "PluginFieldsContainer", params={"range": "0-1"} + ) assert out == [{"id": 1}] get_call = next( call for call in http.calls if call["url"].endswith("/PluginFieldsContainer") diff --git a/glpi_python_client/_sync/clients/api/administration/__init__.py b/glpi_python_client/_sync/clients/api/administration/__init__.py index 497758c..3780e6f 100644 --- a/glpi_python_client/_sync/clients/api/administration/__init__.py +++ b/glpi_python_client/_sync/clients/api/administration/__init__.py @@ -1,8 +1,8 @@ """GLPI ``/Administration`` mixins for the GLPI client. -The submodules expose the user and entity mixins used by -:class:`glpi_python_client.GlpiClient` and -:class:`glpi_python_client.AsyncGlpiClient`. +The submodules expose the user and entity mixins used by this tree's +client -- :class:`glpi_python_client.AsyncGlpiClient` on the async +surface, :class:`glpi_python_client.GlpiClient` on the generated one. """ from __future__ import annotations diff --git a/glpi_python_client/_sync/clients/api/administration/tests/__init__.py b/glpi_python_client/_sync/clients/api/administration/tests/__init__.py new file mode 100644 index 0000000..fad2619 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the user and entity endpoint mixins.""" diff --git a/glpi_python_client/_sync/clients/api/administration/tests/test_entity.py b/glpi_python_client/_sync/clients/api/administration/tests/test_entity.py new file mode 100644 index 0000000..8cfb417 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_entity.py @@ -0,0 +1,195 @@ +"""Unit tests for the ``Administration/Entity`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI entities, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchEntity, PostEntity +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + + +def test_search_entities_skips_entity_header(client: Any) -> None: + """``search_entities`` skips the GLPI-Entity header.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "root"}]) + rec.install(client) + entities = client.search_entities("name==root", limit=None, start=0) + assert entities[0].id == 1 + assert rec.calls[0]["skip_entity"] is True + assert "limit" not in rec.calls[0]["params"] + + +def test_get_entity_skips_entity_header(client: Any) -> None: + """``get_entity`` also bypasses the entity header.""" + + rec = TransportRecorder(get_payload={"id": 2, "name": "root"}) + rec.install(client) + entity = client.get_entity(2) + assert entity.id == 2 + assert rec.calls[0]["endpoint"] == "Administration/Entity/2" + assert rec.calls[0]["skip_entity"] is True + + +def test_update_entity_patch(client: Any) -> None: + """``update_entity`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_entity(2, PatchEntity(name="renamed")) + assert rec.calls[0]["endpoint"] == "Administration/Entity/2" + + +def test_delete_entity_with_force(client: Any) -> None: + """``delete_entity(force=True)`` ships the force flag and skips entity.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_entity(2, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Entity/2" + assert call["json"] == {"force": True} + assert call["skip_entity"] is True + + +def test_create_entity_id_returned(client: Any) -> None: + """``create_entity`` returns the newly created identifier.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + entity_id = client.create_entity(PostEntity(name="root")) + assert entity_id == 42 + assert rec.calls[0]["endpoint"] == "Administration/Entity" + assert rec.calls[0]["skip_entity"] is True + + +def test_create_entity_skips_entity_header(client: Any) -> None: + """Entity create requests bypass the GLPI-Entity header.""" + + rec = TransportRecorder() + rec.install(client) + client.create_entity(PostEntity(name="root")) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Entity" + assert call["skip_entity"] is True + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_entity(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_entity(1, PatchEntity(name="x")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_entity(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +# --------------------------------------------------------------------------- +# iter_search_entities +# --------------------------------------------------------------------------- + + +def test_iter_search_entities_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "name": "root"}] + + client.search_entities = fake_search # type: ignore[method-assign] + batches = [b for b in client.iter_search_entities("", batch_size=50)] + assert call_count == 1 + assert len(batches) == 1 + + +def test_iter_search_entities_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first short entity batch.""" + + responses = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_entities = fake_search # type: ignore[method-assign] + batches = [batch for batch in client.iter_search_entities("", batch_size=2)] + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_sync/clients/api/administration/tests/test_user.py b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py new file mode 100644 index 0000000..56d94bf --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py @@ -0,0 +1,194 @@ +"""Unit tests for the ``Administration/User`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI users, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchUser, PostUser +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + + +def test_search_users_forwards_skip_entity(client: Any) -> None: + """``search_users`` forwards the ``skip_entity`` flag.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "username": "alice"}]) + rec.install(client) + users = client.search_users("username==alice", skip_entity=True) + assert len(users) == 1 + assert rec.calls[0]["skip_entity"] is True + assert rec.calls[0]["params"]["filter"] == "username==alice" + + +def test_get_user_targets_user_endpoint(client: Any) -> None: + """``get_user`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 5, "username": "alice"}) + rec.install(client) + user = client.get_user(5) + assert user.id == 5 + assert rec.calls[0]["endpoint"] == "Administration/User/5" + + +def test_update_user_sends_patch(client: Any) -> None: + """``update_user`` issues PATCH against the user endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_user(5, PatchUser(firstname="Alice")) + assert rec.calls[0]["method"] == "PATCH" + assert rec.calls[0]["endpoint"] == "Administration/User/5" + + +def test_create_user_serialises_post_body(client: Any) -> None: + """``create_user`` serialises the ``PostUser`` model into the POST body.""" + + rec = TransportRecorder() + rec.install(client) + user_id = client.create_user(PostUser(username="alice")) + assert user_id == 999 + assert rec.calls == [ + { + "method": "POST", + "endpoint": "Administration/User", + "json": {"username": "alice"}, + "skip_entity": False, + } + ] + + +def test_delete_user_supports_force_flag(client: Any) -> None: + """``delete_user`` forwards the ``force`` flag inside the JSON body.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_user(5, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Administration/User/5" + assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_user(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_user(1, PatchUser(firstname="x")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_user(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +# --------------------------------------------------------------------------- +# iter_search_users +# --------------------------------------------------------------------------- + + +def test_iter_search_users_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "username": "alice"}] + + client.search_users = fake_search # type: ignore[method-assign] + batches = [ + b for b in client.iter_search_users("username==alice", batch_size=50) + ] + assert call_count == 1 + assert len(batches) == 1 + + +def test_iter_search_users_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first short user batch.""" + + responses = [ + [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}], + [{"id": 3, "username": "carol"}], + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_users = fake_search # type: ignore[method-assign] + batches = [batch for batch in client.iter_search_users("", batch_size=2)] + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_sync/clients/api/assistance/tests/__init__.py b/glpi_python_client/_sync/clients/api/assistance/tests/__init__.py new file mode 100644 index 0000000..3fba735 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ticket and ticket-team endpoint mixins.""" diff --git a/glpi_python_client/_sync/clients/api/assistance/tests/test_team.py b/glpi_python_client/_sync/clients/api/assistance/tests/test_team.py new file mode 100644 index 0000000..475e4d8 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/tests/test_team.py @@ -0,0 +1,97 @@ +"""Unit tests for the ``Assistance/Ticket/TeamMember`` endpoint mixin. + +The tests cover listing, adding, and removing ticket team members, using +the shared transport recorders to stub the four transport helpers without +any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PostTeamMember +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +def test_list_ticket_team_members_endpoint(client: Any) -> None: + """``list_ticket_team_members`` hits the team-member endpoint.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "type": "User", "role": "assigned"}]) + rec.install(client) + members = client.list_ticket_team_members(7) + assert members[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/TeamMember" + + +def test_add_ticket_team_member_targets_team_endpoint(client: Any) -> None: + """``add_ticket_team_member`` posts to the ticket team-member endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.add_ticket_team_member( + 11, PostTeamMember(type="User", id=42, role="assigned") + ) + + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/11/TeamMember" + assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} + + +def test_remove_ticket_team_member_uses_delete(client: Any) -> None: + """``remove_ticket_team_member`` issues DELETE with the member body.""" + + rec = TransportRecorder() + rec.install(client) + client.remove_ticket_team_member( + 7, PostTeamMember(type="User", id=42, role="assigned") + ) + + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Assistance/Ticket/7/TeamMember" + assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_ticket_team_members(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.remove_ticket_team_member( + 1, PostTeamMember(type="User", id=2, role="assigned") + ), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py b/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py new file mode 100644 index 0000000..e7cfeba --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/tests/test_ticket.py @@ -0,0 +1,273 @@ +"""Unit tests for the ``Assistance/Ticket`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, and page-by-page +iteration for GLPI tickets, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTicket, PostTicket +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Tickets +# --------------------------------------------------------------------------- + + +def test_search_tickets_forwards_sort_and_fields(client: Any) -> None: + """Sort and field selection both flow into the GET query parameters.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "n", "content": "c"}]) + rec.install(client) + tickets = client.search_tickets( + "status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name") + ) + + assert len(tickets) == 1 + assert rec.calls[0]["params"]["filter"] == "status==1" + assert rec.calls[0]["params"]["limit"] == 5 + assert rec.calls[0]["params"]["start"] == 10 + assert rec.calls[0]["params"]["sort"] == "date_mod desc" + assert rec.calls[0]["params"]["fields"] == "id,name" + + +def test_search_tickets_uses_filter_query_param(client: Any) -> None: + """``search_tickets`` forwards the RSQL filter via the ``filter`` parameter.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "demo"}]) + rec.install(client) + tickets = client.search_tickets(rsql_filter="status==1", limit=20) + assert len(tickets) == 1 + assert rec.calls[0]["method"] == "GET" + assert rec.calls[0]["endpoint"] == "Assistance/Ticket" + assert rec.calls[0]["params"]["filter"] == "status==1" + assert rec.calls[0]["params"]["limit"] == 20 + + +def test_get_ticket_returns_validated_model(client: Any) -> None: + """Single ticket responses are validated through ``GetTicket``.""" + + rec = TransportRecorder( + get_payload={"id": 7, "name": "demo", "content": "c
"} + ) + rec.install(client) + ticket = client.get_ticket(7) + assert ticket.id == 7 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7" + + +def test_create_ticket_serialises_enums(client: Any) -> None: + """``create_ticket`` serialises enum values as their numeric form.""" + + rec = TransportRecorder() + rec.install(client) + client.create_ticket(PostTicket(name="t", content="c
")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket" + assert call["json"]["name"] == "t" + + +def test_update_ticket_sends_patch(client: Any) -> None: + """Update sends a PATCH with the partial body.""" + + rec = TransportRecorder() + rec.install(client) + client.update_ticket(7, PatchTicket(content="x
")) + call = rec.calls[0] + assert call["method"] == "PATCH" + assert call["endpoint"] == "Assistance/Ticket/7" + assert call["json"] == {"content": "x
"} + + +def test_delete_ticket_omits_body_without_force(client: Any) -> None: + """``delete_ticket(force=None)`` omits the JSON body.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_ticket(7) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Assistance/Ticket/7" + assert call["json"] is None + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket(1, PatchTicket(content="x
")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +# --------------------------------------------------------------------------- +# iter_search_tickets +# --------------------------------------------------------------------------- + + +def test_iter_search_tickets_single_page(client: Any) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + pages: list[list[Any]] = [[{"id": 1, "name": "t1", "content": "c"}]] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return pages[0] + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [b for b in client.iter_search_tickets("status==1", batch_size=50)] + assert call_count == 1 + assert len(batches) == 1 + assert len(batches[0]) == 1 + + +def test_iter_search_tickets_multi_page_stops_on_short_batch( + client: Any, +) -> None: + """Iteration stops after the first batch shorter than batch_size.""" + + ticket_a = {"id": 1, "name": "a", "content": "c"} + ticket_b = {"id": 2, "name": "b", "content": "c"} + ticket_c = {"id": 3, "name": "c", "content": "c"} + responses = [ + [ticket_a, ticket_b], # full page → continue + [ticket_c], # short page → last + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [batch for batch in client.iter_search_tickets("", batch_size=2)] + assert call_count == 2 + assert len(batches) == 2 + assert len(batches[0]) == 2 + assert len(batches[1]) == 1 + + +def test_iter_search_tickets_forwards_pagination_and_query_options( + client: Any, +) -> None: + """Each page advances ``start`` and repeats the caller's query options. + + The sibling tests above count pages and stub the search away, so they + pass whatever the generator sends. This one records every call: it is + the only thing pinning the offset arithmetic and the per-page + forwarding of ``filter``, ``sort`` and ``fields``. + """ + + ticket = {"id": 1, "name": "a", "content": "c"} + responses = [[ticket, ticket], [ticket, ticket], [ticket]] + calls: list[dict[str, Any]] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + calls.append( + { + "rsql_filter": rsql_filter, + "limit": limit, + "start": start, + "sort": sort, + "fields": fields, + } + ) + return responses[len(calls) - 1] + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = [ + batch + for batch in client.iter_search_tickets( + "status==1", batch_size=2, sort="id:desc", fields=("id", "name") + ) + ] + + assert len(batches) == 3 + # One full page per step. A generator that failed to advance the offset + # would re-request page zero forever against a real server. + assert [call["start"] for call in calls] == [0, 2, 4] + assert [call["limit"] for call in calls] == [2, 2, 2] + # Every page repeats the caller's options. Forwarding them only on the + # first request would silently change the sort order and widen the + # field set from page two onwards. + assert all(call["rsql_filter"] == "status==1" for call in calls) + assert all(call["sort"] == "id:desc" for call in calls) + assert all(call["fields"] == ("id", "name") for call in calls) diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/__init__.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/__init__.py new file mode 100644 index 0000000..18a2565 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ticket timeline endpoint mixins.""" diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py new file mode 100644 index 0000000..c603359 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py @@ -0,0 +1,110 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Document`` endpoint mixin. + +The tests cover listing, fetching, linking, updating, and unlinking ticket +timeline documents, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTimelineDocument, PostTimelineDocument +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +def test_list_get_update_unlink_timeline_documents(client: Any) -> None: + """All four timeline document helpers target the document endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "Document_Item", "item": {"id": 1, "filename": "report.txt"}}, + ] + ) + rec.install(client) + items = client.list_ticket_timeline_documents(7) + assert items[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Document" + + rec._get_payload = {"id": 1, "filename": "report.txt"} # type: ignore[attr-defined] + doc = client.get_ticket_timeline_document(7, 1) + assert doc.id == 1 + + client.update_ticket_timeline_document(7, 1, PatchTimelineDocument()) + client.unlink_ticket_timeline_document(7, 1, force=True) + + methods = [c["method"] for c in rec.calls] + assert methods == ["GET", "GET", "PATCH", "DELETE"] + + +def test_link_ticket_timeline_document_targets_document_endpoint( + client: Any, +) -> None: + """``link_ticket_timeline_document`` targets the document timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.link_ticket_timeline_document(10, PostTimelineDocument()) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/10/Timeline/Document" + assert call["json"] == {} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_timeline_document(1, 2), + lambda c: c.list_ticket_timeline_documents(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_timeline_document(1, 2, PatchTimelineDocument()), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.unlink_ticket_timeline_document(1, 2, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_followup.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_followup.py new file mode 100644 index 0000000..bf5eb00 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_followup.py @@ -0,0 +1,127 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Followup`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +followups, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchFollowup, PostFollowup +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +def test_list_ticket_followups_unwraps_envelope(client: Any) -> None: + """Live envelope ``{"type":..,"item":..}`` entries are unwrapped.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "ITILFollowup", "item": {"id": 11, "content": "hi"}}, + {"id": 12, "content": "bye"}, + ] + ) + rec.install(client) + items = client.list_ticket_followups(7) + assert [i.id for i in items] == [11, 12] + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" + + +def test_get_ticket_followup_endpoint(client: Any) -> None: + """``get_ticket_followup`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 11, "content": "x"}) + rec.install(client) + followup = client.get_ticket_followup(7, 11) + assert followup.id == 11 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" + + +def test_update_ticket_followup_patch(client: Any) -> None: + """``update_ticket_followup`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_ticket_followup(7, 11, PatchFollowup(content="up
")) + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" + + +def test_delete_ticket_followup_force(client: Any) -> None: + """``delete_ticket_followup(force=True)`` adds the body.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_ticket_followup(7, 11, force=True) + assert rec.calls[0]["json"] == {"force": True} + + +def test_create_ticket_followup_targets_timeline_endpoint(client: Any) -> None: + """``create_ticket_followup`` posts to the ticket timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.create_ticket_followup(7, PostFollowup(content="hi
")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" + assert call["json"] == {"content": "hi
"} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_followup(1, 2), + lambda c: c.list_ticket_followups(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_followup(1, 2, PatchFollowup(content="x
")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_followup(1, 2, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_solution.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_solution.py new file mode 100644 index 0000000..7dcc664 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_solution.py @@ -0,0 +1,110 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Solution`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +solutions, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchSolution, PostSolution +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +def test_list_get_update_delete_ticket_solutions(client: Any) -> None: + """All four solution helpers target the solution timeline endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "ITILSolution", "item": {"id": 1, "content": "x"}}, + ] + ) + rec.install(client) + sols = client.list_ticket_solutions(7) + assert sols[0].id == 1 + + rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] + sol = client.get_ticket_solution(7, 1) + assert sol.id == 1 + + client.update_ticket_solution(7, 1, PatchSolution(content="up
")) + client.delete_ticket_solution(7, 1, force=True) + + methods = [c["method"] for c in rec.calls] + assert methods == ["GET", "GET", "PATCH", "DELETE"] + endpoints = {c["endpoint"] for c in rec.calls if c["method"] != "GET"} | { + c["endpoint"] for c in rec.calls if c["method"] == "GET" + } + assert any("Solution" in e for e in endpoints) + + +def test_create_ticket_solution_uses_solution_endpoint(client: Any) -> None: + """``create_ticket_solution`` targets the ticket solution endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.create_ticket_solution(9, PostSolution(content="ok")) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/9/Timeline/Solution" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_solution(1, 2), + lambda c: c.list_ticket_solutions(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_solution(1, 2, PatchSolution(content="x
")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_solution(1, 2, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_task.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_task.py new file mode 100644 index 0000000..4f7f0d9 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_task.py @@ -0,0 +1,113 @@ +"""Unit tests for the ``Assistance/Ticket/Timeline/Task`` endpoint mixin. + +The tests cover listing, fetching, creating, updating, and deleting ticket +tasks, using the shared transport recorders to stub the four transport +helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchTicketTask, PostTicketTask +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + + +def test_list_get_update_delete_ticket_tasks(client: Any) -> None: + """All four task helpers target the task timeline endpoint.""" + + rec = TransportRecorder( + get_payload=[ + {"type": "TicketTask", "item": {"id": 1, "content": "x"}}, + ] + ) + rec.install(client) + tasks = client.list_ticket_tasks(7) + assert tasks[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Task" + + rec.calls.clear() + rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] + task = client.get_ticket_task(7, 1) + assert task.id == 1 + + client.update_ticket_task(7, 1, PatchTicketTask(content="up
")) + client.delete_ticket_task(7, 1, force=True) + + endpoints = [c["endpoint"] for c in rec.calls] + assert endpoints == [ + "Assistance/Ticket/7/Timeline/Task/1", + "Assistance/Ticket/7/Timeline/Task/1", + "Assistance/Ticket/7/Timeline/Task/1", + ] + + +def test_create_ticket_task_uses_task_endpoint(client: Any) -> None: + """``create_ticket_task`` targets the ticket task timeline endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.create_ticket_task(8, PostTicketTask(content="task", duration=120)) + call = rec.calls[0] + assert call["endpoint"] == "Assistance/Ticket/8/Timeline/Task" + assert call["json"] == {"content": "task
", "duration": 120} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_ticket_task(1, 2), + lambda c: c.list_ticket_tasks(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_ticket_task(1, 2, PatchTicketTask(content="x
")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_ticket_task(1, 2, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/dropdowns/tests/__init__.py b/glpi_python_client/_sync/clients/api/dropdowns/tests/__init__.py new file mode 100644 index 0000000..009b736 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/dropdowns/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the dropdown endpoint mixins.""" diff --git a/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py new file mode 100644 index 0000000..72c13a8 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_location.py @@ -0,0 +1,128 @@ +"""Unit tests for the ``Dropdowns/Location`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +locations, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchLocation, PostLocation +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Locations +# --------------------------------------------------------------------------- + + +def test_search_locations_passes_filter(client: Any) -> None: + """``search_locations`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Paris"}]) + rec.install(client) + locations = client.search_locations("name==Paris") + assert locations[0].id == 1 + assert rec.calls[0]["endpoint"] == "Dropdowns/Location" + assert rec.calls[0]["params"]["filter"] == "name==Paris" + + +def test_get_location_endpoint(client: Any) -> None: + """``get_location`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Paris"}) + rec.install(client) + loc = client.get_location(9) + assert loc.id == 9 + assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" + + +def test_update_location(client: Any) -> None: + """``update_location`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_location(9, PatchLocation(name="Paris HQ")) + assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" + + +def test_delete_location_with_force(client: Any) -> None: + """``delete_location(force=True)`` ships the force flag in the body.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_location(9, force=True) + call = rec.calls[0] + assert call["method"] == "DELETE" + assert call["endpoint"] == "Dropdowns/Location/9" + assert call["json"] == {"force": True} + + +def test_create_location_targets_dropdown_endpoint(client: Any) -> None: + """``create_location`` posts to the dropdown endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.create_location(PostLocation(name="Paris")) + call = rec.calls[0] + assert call["endpoint"] == "Dropdowns/Location" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_location(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_location(1, PatchLocation(name="x")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_location(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/tests/__init__.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/__init__.py new file mode 100644 index 0000000..e59b662 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the knowledge base endpoint mixins.""" diff --git a/glpi_python_client/tests/api_knowledgebase/test_article_mixin.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py similarity index 76% rename from glpi_python_client/tests/api_knowledgebase/test_article_mixin.py rename to glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py index c902acc..a0a2f67 100644 --- a/glpi_python_client/tests/api_knowledgebase/test_article_mixin.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_article.py @@ -2,18 +2,19 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import pytest from glpi_python_client import ( - GlpiClient, GlpiValidationError, IdNameRef, PatchKBArticle, PostKBArticle, ) -from glpi_python_client.testing.utils import FakeResponse, make_client +from glpi_python_client._sync._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse class _FakeV1: @@ -45,6 +46,9 @@ def request_json( raise self._error return [{"1": True, "message": ""}] + def close(self) -> None: + """No-op; the real session is closed with the client.""" + class _Recorder: """Transport recorder returning canned FakeResponse objects.""" @@ -53,7 +57,7 @@ def __init__(self, *, get_payload: Any = None) -> None: self.calls: list[dict[str, Any]] = [] self._get_payload = get_payload if get_payload is not None else [] - def install(self, client: GlpiClient) -> None: + def install(self, client: Any) -> None: def _get( endpoint: str, params: dict[str, Any] | None = None, @@ -103,12 +107,7 @@ def _delete( client._delete_request = _delete # type: ignore[method-assign] -@pytest.fixture -def client() -> GlpiClient: - return make_client() - - -def test_search_kb_articles_forwards_params(client: GlpiClient) -> None: +def test_search_kb_articles_forwards_params(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "name": "Reset", "content": "c
"}]) rec.install(client) result = client.search_kb_articles( @@ -124,7 +123,7 @@ def test_search_kb_articles_forwards_params(client: GlpiClient) -> None: assert call["params"]["language"] == "en_GB" -def test_get_kb_article_targets_per_id_endpoint(client: GlpiClient) -> None: +def test_get_kb_article_targets_per_id_endpoint(client: Any) -> None: rec = _Recorder(get_payload={"id": 5, "name": "Reset", "content": "c
"}) rec.install(client) article = client.get_kb_article(5) @@ -132,7 +131,7 @@ def test_get_kb_article_targets_per_id_endpoint(client: GlpiClient) -> None: assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5" -def test_create_kb_article_renders_markdown_and_returns_id(client: GlpiClient) -> None: +def test_create_kb_article_renders_markdown_and_returns_id(client: Any) -> None: rec = _Recorder() rec.install(client) new_id = client.create_kb_article( @@ -146,7 +145,7 @@ def test_create_kb_article_renders_markdown_and_returns_id(client: GlpiClient) - assert "passwd" in call["json"]["content"] -def test_update_kb_article_sends_patch(client: GlpiClient) -> None: +def test_update_kb_article_sends_patch(client: Any) -> None: rec = _Recorder() rec.install(client) client.update_kb_article(5, PatchKBArticle(is_pinned=True)) @@ -156,7 +155,7 @@ def test_update_kb_article_sends_patch(client: GlpiClient) -> None: assert call["json"] == {"is_pinned": True} -def test_delete_kb_article_with_force(client: GlpiClient) -> None: +def test_delete_kb_article_with_force(client: Any) -> None: rec = _Recorder() rec.install(client) client.delete_kb_article(5, force=True) @@ -166,7 +165,7 @@ def test_delete_kb_article_with_force(client: GlpiClient) -> None: assert call["json"] == {"force": True} -def test_set_kb_article_categories_writes_via_v1(client: GlpiClient) -> None: +def test_set_kb_article_categories_writes_via_v1(client: Any) -> None: fake = _FakeV1() client._v1 = fake # type: ignore[assignment] client.set_kb_article_categories(31, [14, 15]) @@ -176,20 +175,20 @@ def test_set_kb_article_categories_writes_via_v1(client: GlpiClient) -> None: assert call["json_body"] == {"input": {"_categories": [14, 15]}} -def test_set_kb_article_categories_empty_clears(client: GlpiClient) -> None: +def test_set_kb_article_categories_empty_clears(client: Any) -> None: fake = _FakeV1() client._v1 = fake # type: ignore[assignment] client.set_kb_article_categories(31, []) assert fake.calls[0]["json_body"] == {"input": {"_categories": []}} -def test_set_kb_article_categories_requires_v1(client: GlpiClient) -> None: +def test_set_kb_article_categories_requires_v1(client: Any) -> None: assert client._v1 is None with pytest.raises(RuntimeError): client.set_kb_article_categories(31, [14]) -def test_create_kb_article_applies_categories_via_v1(client: GlpiClient) -> None: +def test_create_kb_article_applies_categories_via_v1(client: Any) -> None: rec = _Recorder() rec.install(client) fake = _FakeV1() @@ -203,7 +202,7 @@ def test_create_kb_article_applies_categories_via_v1(client: GlpiClient) -> None assert fake.calls[0]["json_body"] == {"input": {"_categories": [14]}} -def test_create_kb_article_without_categories_skips_v1(client: GlpiClient) -> None: +def test_create_kb_article_without_categories_skips_v1(client: Any) -> None: rec = _Recorder() rec.install(client) assert client._v1 is None # no v1 configured @@ -212,7 +211,7 @@ def test_create_kb_article_without_categories_skips_v1(client: GlpiClient) -> No def test_create_kb_article_category_failure_raises_without_rollback( - client: GlpiClient, + client: Any, ) -> None: rec = _Recorder() rec.install(client) @@ -228,7 +227,7 @@ def test_create_kb_article_category_failure_raises_without_rollback( assert "boom" in str(excinfo.value.__cause__) -def test_create_kb_article_ref_without_id_raises(client: GlpiClient) -> None: +def test_create_kb_article_ref_without_id_raises(client: Any) -> None: """``create_kb_article`` wraps the failure in ``RuntimeError`` (kept bare by design), chaining the underlying ``GlpiValidationError`` as its cause. """ @@ -245,7 +244,7 @@ def test_create_kb_article_ref_without_id_raises(client: GlpiClient) -> None: assert isinstance(excinfo.value.__cause__, ValueError) -def test_create_kb_article_empty_categories_skips_v1(client: GlpiClient) -> None: +def test_create_kb_article_empty_categories_skips_v1(client: Any) -> None: rec = _Recorder() rec.install(client) assert client._v1 is None # no v1 configured @@ -256,7 +255,7 @@ def test_create_kb_article_empty_categories_skips_v1(client: GlpiClient) -> None assert not any(c["method"] == "DELETE" for c in rec.calls) # no legacy call -def test_update_kb_article_applies_categories_via_v1(client: GlpiClient) -> None: +def test_update_kb_article_applies_categories_via_v1(client: Any) -> None: rec = _Recorder() rec.install(client) fake = _FakeV1() @@ -267,7 +266,7 @@ def test_update_kb_article_applies_categories_via_v1(client: GlpiClient) -> None assert fake.calls[0]["json_body"] == {"input": {"_categories": [14]}} -def test_update_kb_article_without_categories_skips_v1(client: GlpiClient) -> None: +def test_update_kb_article_without_categories_skips_v1(client: Any) -> None: rec = _Recorder() rec.install(client) assert client._v1 is None @@ -276,7 +275,7 @@ def test_update_kb_article_without_categories_skips_v1(client: GlpiClient) -> No def test_update_kb_article_category_failure_does_not_roll_back( - client: GlpiClient, + client: Any, ) -> None: rec = _Recorder() rec.install(client) @@ -285,3 +284,64 @@ def test_update_kb_article_category_failure_does_not_roll_back( client.update_kb_article(5, PatchKBArticle(categories=[IdNameRef(id=14)])) # Update is intentionally non-atomic: the v2 patch stays, no rollback delete. assert not any(c["method"] == "DELETE" for c in rec.calls) + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_kb_article(1), + ], +) +def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_article(PostKBArticle(name="x")), + ], +) +def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_article(1, PatchKBArticle(name="x")), + ], +) +def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_article(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/tests/api_knowledgebase/test_category_mixin.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py similarity index 65% rename from glpi_python_client/tests/api_knowledgebase/test_category_mixin.py rename to glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py index a20bedb..f4deceb 100644 --- a/glpi_python_client/tests/api_knowledgebase/test_category_mixin.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_category.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import pytest -from glpi_python_client import GlpiClient, PatchKBCategory, PostKBCategory -from glpi_python_client.testing.utils import FakeResponse, make_client +from glpi_python_client import PatchKBCategory, PostKBCategory +from glpi_python_client._sync._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse class _Recorder: @@ -17,7 +19,7 @@ def __init__(self, *, get_payload: Any = None) -> None: self.calls: list[dict[str, Any]] = [] self._get_payload = get_payload if get_payload is not None else [] - def install(self, client: GlpiClient) -> None: + def install(self, client: Any) -> None: def _get( endpoint: str, params: dict[str, Any] | None = None, @@ -67,12 +69,7 @@ def _delete( client._delete_request = _delete # type: ignore[method-assign] -@pytest.fixture -def client() -> GlpiClient: - return make_client() - - -def test_search_kb_categories_forwards_filter_and_language(client: GlpiClient) -> None: +def test_search_kb_categories_forwards_filter_and_language(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "name": "Network"}]) rec.install(client) result = client.search_kb_categories( @@ -88,7 +85,7 @@ def test_search_kb_categories_forwards_filter_and_language(client: GlpiClient) - assert call["params"]["language"] == "fr_FR" -def test_get_kb_category_targets_per_id_endpoint(client: GlpiClient) -> None: +def test_get_kb_category_targets_per_id_endpoint(client: Any) -> None: rec = _Recorder(get_payload={"id": 9, "name": "Network"}) rec.install(client) category = client.get_kb_category(9) @@ -96,7 +93,7 @@ def test_get_kb_category_targets_per_id_endpoint(client: GlpiClient) -> None: assert rec.calls[0]["endpoint"] == "Knowledgebase/Category/9" -def test_create_kb_category_returns_new_id(client: GlpiClient) -> None: +def test_create_kb_category_returns_new_id(client: Any) -> None: rec = _Recorder() rec.install(client) new_id = client.create_kb_category(PostKBCategory(name="Network")) @@ -107,7 +104,7 @@ def test_create_kb_category_returns_new_id(client: GlpiClient) -> None: assert call["json"] == {"name": "Network"} -def test_update_kb_category_sends_patch(client: GlpiClient) -> None: +def test_update_kb_category_sends_patch(client: Any) -> None: rec = _Recorder() rec.install(client) client.update_kb_category(9, PatchKBCategory(comment="moved")) @@ -117,7 +114,7 @@ def test_update_kb_category_sends_patch(client: GlpiClient) -> None: assert call["json"] == {"comment": "moved"} -def test_delete_kb_category_with_force(client: GlpiClient) -> None: +def test_delete_kb_category_with_force(client: Any) -> None: rec = _Recorder() rec.install(client) client.delete_kb_category(9, force=True) @@ -125,3 +122,64 @@ def test_delete_kb_category_with_force(client: GlpiClient) -> None: assert call["method"] == "DELETE" assert call["endpoint"] == "Knowledgebase/Category/9" assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_kb_category(1), + ], +) +def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_category(PostKBCategory(name="x")), + ], +) +def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_category(1, PatchKBCategory(name="x")), + ], +) +def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_category(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_comment.py similarity index 61% rename from glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py rename to glpi_python_client/_sync/clients/api/knowledgebase/tests/test_comment.py index 50f393a..a590e78 100644 --- a/glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_comment.py @@ -2,16 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import pytest from glpi_python_client import ( - GlpiClient, PatchKBArticleComment, PostKBArticleComment, ) -from glpi_python_client.testing.utils import FakeResponse, make_client +from glpi_python_client._sync._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse class _Recorder: @@ -19,7 +20,7 @@ def __init__(self, *, get_payload: Any = None) -> None: self.calls: list[dict[str, Any]] = [] self._get_payload = get_payload if get_payload is not None else [] - def install(self, client: GlpiClient) -> None: + def install(self, client: Any) -> None: def _get( endpoint: str, params: dict[str, Any] | None = None, @@ -69,12 +70,7 @@ def _delete( client._delete_request = _delete # type: ignore[method-assign] -@pytest.fixture -def client() -> GlpiClient: - return make_client() - - -def test_list_kb_article_comments_targets_nested_endpoint(client: GlpiClient) -> None: +def test_list_kb_article_comments_targets_nested_endpoint(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 1, "comment": "hi"}]) rec.install(client) comments = client.list_kb_article_comments(5) @@ -82,7 +78,7 @@ def test_list_kb_article_comments_targets_nested_endpoint(client: GlpiClient) -> assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Comment" -def test_get_kb_article_comment_targets_per_id_endpoint(client: GlpiClient) -> None: +def test_get_kb_article_comment_targets_per_id_endpoint(client: Any) -> None: rec = _Recorder(get_payload={"id": 7, "comment": "hi"}) rec.install(client) comment = client.get_kb_article_comment(5, 7) @@ -90,26 +86,30 @@ def test_get_kb_article_comment_targets_per_id_endpoint(client: GlpiClient) -> N assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Comment/7" -def test_create_kb_article_comment_returns_id(client: GlpiClient) -> None: +def test_create_kb_article_comment_returns_id(client: Any) -> None: rec = _Recorder() rec.install(client) - new_id = client.create_kb_article_comment(5, PostKBArticleComment(comment="hi")) + new_id = client.create_kb_article_comment( + 5, PostKBArticleComment(comment="hi") + ) assert new_id == 77 call = rec.calls[0] assert call["endpoint"] == "Knowledgebase/Article/5/Comment" assert call["json"] == {"comment": "hi"} -def test_update_kb_article_comment_sends_patch(client: GlpiClient) -> None: +def test_update_kb_article_comment_sends_patch(client: Any) -> None: rec = _Recorder() rec.install(client) - client.update_kb_article_comment(5, 7, PatchKBArticleComment(comment="edited")) + client.update_kb_article_comment( + 5, 7, PatchKBArticleComment(comment="edited") + ) call = rec.calls[0] assert call["method"] == "PATCH" assert call["endpoint"] == "Knowledgebase/Article/5/Comment/7" -def test_delete_kb_article_comment_with_force(client: GlpiClient) -> None: +def test_delete_kb_article_comment_with_force(client: Any) -> None: rec = _Recorder() rec.install(client) client.delete_kb_article_comment(5, 7, force=True) @@ -117,3 +117,65 @@ def test_delete_kb_article_comment_with_force(client: GlpiClient) -> None: assert call["method"] == "DELETE" assert call["endpoint"] == "Knowledgebase/Article/5/Comment/7" assert call["json"] == {"force": True} + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_kb_article_comments(1), + lambda c: c.get_kb_article_comment(1, 2), + ], +) +def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.create_kb_article_comment(1, PostKBArticleComment(comment="x")), + ], +) +def test_create_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_kb_article_comment(1, 2, PatchKBArticleComment(comment="x")), + ], +) +def test_update_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_kb_article_comment(1, 2, force=True), + ], +) +def test_delete_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_revision.py similarity index 60% rename from glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py rename to glpi_python_client/_sync/clients/api/knowledgebase/tests/test_revision.py index 2dfd4c7..47fdac1 100644 --- a/glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/tests/test_revision.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import pytest -from glpi_python_client import GlpiClient -from glpi_python_client.testing.utils import FakeResponse, make_client +from glpi_python_client._sync._testing import FailingTransportRecorder +from glpi_python_client.testing.utils import FakeResponse class _Recorder: @@ -15,7 +16,7 @@ def __init__(self, *, get_payload: Any = None) -> None: self.calls: list[dict[str, Any]] = [] self._get_payload = get_payload if get_payload is not None else [] - def install(self, client: GlpiClient) -> None: + def install(self, client: Any) -> None: def _get( endpoint: str, params: dict[str, Any] | None = None, @@ -27,12 +28,7 @@ def _get( client._get_request = _get # type: ignore[method-assign] -@pytest.fixture -def client() -> GlpiClient: - return make_client() - - -def test_list_kb_article_revisions_default_language(client: GlpiClient) -> None: +def test_list_kb_article_revisions_default_language(client: Any) -> None: rec = _Recorder(get_payload=[{"id": 11, "revision": 2}]) rec.install(client) revisions = client.list_kb_article_revisions(5) @@ -41,7 +37,7 @@ def test_list_kb_article_revisions_default_language(client: GlpiClient) -> None: def test_list_kb_article_revisions_with_language_uses_path_segment( - client: GlpiClient, + client: Any, ) -> None: rec = _Recorder(get_payload=[{"id": 11, "revision": 2}]) rec.install(client) @@ -49,7 +45,7 @@ def test_list_kb_article_revisions_with_language_uses_path_segment( assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/fr_FR/Revision" -def test_get_kb_article_revision_default_language(client: GlpiClient) -> None: +def test_get_kb_article_revision_default_language(client: Any) -> None: rec = _Recorder(get_payload={"id": 11, "revision": 2}) rec.install(client) revision = client.get_kb_article_revision(5, 2) @@ -57,8 +53,31 @@ def test_get_kb_article_revision_default_language(client: GlpiClient) -> None: assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/Revision/2" -def test_get_kb_article_revision_with_language(client: GlpiClient) -> None: +def test_get_kb_article_revision_with_language(client: Any) -> None: rec = _Recorder(get_payload={"id": 11, "revision": 2}) rec.install(client) client.get_kb_article_revision(5, 2, language="fr_FR") assert rec.calls[0]["endpoint"] == "Knowledgebase/Article/5/fr_FR/Revision/2" + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# +# The revision mixin is read-only, so it takes only the read share of the +# shared failure suites; there is no create, update, or delete call to take. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_kb_article_revisions(1), + lambda c: c.get_kb_article_revision(1, 2), + ], +) +def test_read_helpers_raise_on_failure( + client: Any, call: Callable[[Any], Any] +) -> None: + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/management/tests/__init__.py b/glpi_python_client/_sync/clients/api/management/tests/__init__.py new file mode 100644 index 0000000..898ad37 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/management/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the document management endpoint mixin.""" diff --git a/glpi_python_client/_sync/clients/api/management/tests/test_document.py b/glpi_python_client/_sync/clients/api/management/tests/test_document.py new file mode 100644 index 0000000..57dd25e --- /dev/null +++ b/glpi_python_client/_sync/clients/api/management/tests/test_document.py @@ -0,0 +1,226 @@ +"""Unit tests for the ``Management/Document`` endpoint mixin. + +The tests cover search, fetch, create, update, delete, download, and +upload for GLPI documents, using the shared transport recorders to stub +the four transport helpers without any HTTP plumbing. The upload tests +additionally stub the legacy v1 session used for binary uploads. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import GlpiValidationError, PatchDocument, PostDocument +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) + +# --------------------------------------------------------------------------- +# Documents (management) +# --------------------------------------------------------------------------- + + +def test_search_documents_filter_and_pagination(client: Any) -> None: + """``search_documents`` forwards the filter, limit, start, and skip_entity.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "doc"}]) + rec.install(client) + docs = client.search_documents("name==*manual*", limit=10, start=20) + assert len(docs) == 1 + call = rec.calls[0] + assert call["endpoint"] == "Management/Document" + assert call["skip_entity"] is True + assert call["params"]["limit"] == 10 + assert call["params"]["start"] == 20 + assert call["params"]["filter"] == "name==*manual*" + + +def test_get_document_endpoint(client: Any) -> None: + """``get_document`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 3, "name": "doc"}) + rec.install(client) + document = client.get_document(3) + assert document.id == 3 + assert rec.calls[0]["endpoint"] == "Management/Document/3" + + +def test_create_document_returns_id(client: Any) -> None: + """``create_document`` returns the new id and skips entity.""" + + rec = TransportRecorder(post_payload={"id": 77}) + rec.install(client) + document_id = client.create_document(PostDocument(name="manual")) + assert document_id == 77 + assert rec.calls[0]["endpoint"] == "Management/Document" + assert rec.calls[0]["skip_entity"] is True + + +def test_update_document_patches_endpoint(client: Any) -> None: + """``update_document`` issues PATCH on the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_document(3, PatchDocument(name="x")) + assert rec.calls[0]["endpoint"] == "Management/Document/3" + + +def test_delete_document_with_force(client: Any) -> None: + """``delete_document(force=True)`` adds the body and skips entity.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_document(3, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Management/Document/3" + assert call["json"] == {"force": True} + assert call["skip_entity"] is True + + +def test_download_document_returns_bytes(client: Any) -> None: + """``download_document_content`` returns the response bytes.""" + + rec = TransportRecorder( + get_status=200, get_payload={"ignored": True}, get_content=b"\x00ZZ" + ) + rec.install(client) + content = client.download_document_content(3) + assert content == b"\x00ZZ" + assert rec.calls[0]["endpoint"] == "Management/Document/3/Download" + + +def test_download_document_raises_on_failure(client: Any) -> None: + """A non-200 download status raises ``ValueError``.""" + + rec = TransportRecorder(get_status=404, get_payload={"err": "missing"}) + rec.install(client) + with pytest.raises(ValueError): + client.download_document_content(3) + + +def test_upload_document_requires_filename(client: Any) -> None: + """``upload_document`` rejects an empty filename before any HTTP call. + + ``GlpiValidationError`` inherits ``ValueError`` so existing callers that + catch the broader type keep working. + """ + + with pytest.raises(GlpiValidationError, match="filename") as excinfo: + client.upload_document(filename="", content=b"x") + assert isinstance(excinfo.value, ValueError) + + +def test_upload_document_dispatches_to_v1(client: Any) -> None: + """``upload_document`` forwards arguments to the configured v1 session.""" + + captured: dict[str, Any] = {} + + class _FakeV1: + """Stand-in for the legacy v1 session used by document upload.""" + + def upload_document( + self, + filename: str, + content: bytes, + mime_type: str, + *, + document_name: str | None, + ticket_id: int | None, + entity_id: int | None, + ) -> dict[str, object]: + captured.update( + { + "filename": filename, + "content": content, + "mime_type": mime_type, + "document_name": document_name, + "ticket_id": ticket_id, + "entity_id": entity_id, + } + ) + return {"id": 1} + + def close(self) -> None: + """No-op; the real session is closed with the client.""" + + client._v1 = _FakeV1() # type: ignore[assignment] + result = client.upload_document( + filename="a.txt", + content=b"abc", + mime_type="text/plain", + document_name="DocA", + ticket_id=5, + entity_id=2, + ) + + assert result == {"id": 1} + assert captured["filename"] == "a.txt" + assert captured["ticket_id"] == 5 + assert captured["entity_id"] == 2 + + +def test_upload_document_without_v1_raises(client: Any) -> None: + """``upload_document`` requires a v1 session to be configured.""" + + with pytest.raises(RuntimeError): + client.upload_document( + filename="a.bin", + content=b"x", + ) + + +# --------------------------------------------------------------------------- +# Generic error handling (this mixin's share of the shared failure suites) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get_document(1), + ], +) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.update_document(1, PatchDocument(name="x")), + ], +) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.delete_document(1, force=True), + ], +) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) diff --git a/glpi_python_client/_sync/clients/api/plugins/tests/__init__.py b/glpi_python_client/_sync/clients/api/plugins/tests/__init__.py new file mode 100644 index 0000000..620743a --- /dev/null +++ b/glpi_python_client/_sync/clients/api/plugins/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the GLPI plugin endpoint mixins.""" diff --git a/glpi_python_client/tests/api_plugins/test_fields_mixin.py b/glpi_python_client/_sync/clients/api/plugins/tests/test_fields.py similarity index 94% rename from glpi_python_client/tests/api_plugins/test_fields_mixin.py rename to glpi_python_client/_sync/clients/api/plugins/tests/test_fields.py index 0115f72..7316590 100644 --- a/glpi_python_client/tests/api_plugins/test_fields_mixin.py +++ b/glpi_python_client/_sync/clients/api/plugins/tests/test_fields.py @@ -6,7 +6,7 @@ import pytest -from glpi_python_client import GlpiClient, GlpiProtocolError, GlpiValidationError +from glpi_python_client import GlpiProtocolError, GlpiValidationError from glpi_python_client._sync.clients.api.plugins._fields import ( _container_targets_itemtype, _extract_row_id, @@ -15,7 +15,6 @@ from glpi_python_client.models.api_schema.plugins import ( GetPluginFieldsContainer, ) -from glpi_python_client.testing.utils import make_client class _FakeV1: @@ -51,12 +50,8 @@ def request_json( ) return self.responses.pop(0) - -@pytest.fixture -def client() -> GlpiClient: - """Return a client without any HTTP plumbing wired up.""" - - return make_client() + def close(self) -> None: + """No-op; the real session is closed with the client.""" def test_value_itemtype_naming() -> None: @@ -115,7 +110,7 @@ def test_extract_row_id_rejects_unexpected_payload() -> None: assert isinstance(excinfo.value, ValueError) -def test_require_v1_raises_without_session(client: GlpiClient) -> None: +def test_require_v1_raises_without_session(client: Any) -> None: """Every helper raises ``RuntimeError`` when the v1 session is missing.""" assert client._v1 is None @@ -123,7 +118,7 @@ def test_require_v1_raises_without_session(client: GlpiClient) -> None: client.list_plugin_fields_containers() -def test_list_plugin_fields_containers_filters_itemtype(client: GlpiClient) -> None: +def test_list_plugin_fields_containers_filters_itemtype(client: Any) -> None: """Client-side filtering keeps only the containers attached to itemtype.""" fake = _FakeV1( @@ -142,7 +137,7 @@ def test_list_plugin_fields_containers_filters_itemtype(client: GlpiClient) -> N assert fake.calls[0]["params"] == {"range": "0-999"} -def test_list_plugin_fields_fields_filters_by_container(client: GlpiClient) -> None: +def test_list_plugin_fields_fields_filters_by_container(client: Any) -> None: """Field listing applies the optional container filter client-side.""" fake = _FakeV1( @@ -158,7 +153,7 @@ def test_list_plugin_fields_fields_filters_by_container(client: GlpiClient) -> N assert [f.id for f in result] == [1] -def test_list_item_plugin_field_rows_hits_subresource(client: GlpiClient) -> None: +def test_list_item_plugin_field_rows_hits_subresource(client: Any) -> None: """Per-item value rows go through ``/updated
"}} -def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> None: +def test_get_ticket_custom_fields_aggregates_containers(client: Any) -> None: """The high-level helper aggregates per-container values into one mapping.""" fake = _FakeV1( @@ -257,7 +252,7 @@ def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> N assert result == {"extrainfo": {"extrainfofield": "test
"}} -def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> None: +def test_set_ticket_custom_fields_updates_existing_row(client: Any) -> None: """When a row exists the high-level helper PATCHes it in place.""" fake = _FakeV1( @@ -297,7 +292,7 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No assert put["json_body"] == {"input": {"id": 1, "extrainfofield": "new
"}} -def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> None: +def test_set_ticket_custom_fields_creates_when_missing(client: Any) -> None: """When no row exists the high-level helper POSTs a new one.""" fake = _FakeV1( @@ -333,7 +328,7 @@ def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> No } -def test_set_ticket_custom_fields_rejects_unknown_container(client: GlpiClient) -> None: +def test_set_ticket_custom_fields_rejects_unknown_container(client: Any) -> None: """A typo in the container name raises before any write. ``GlpiValidationError`` inherits ``ValueError`` so existing callers that @@ -352,7 +347,7 @@ def test_set_ticket_custom_fields_rejects_unknown_container(client: GlpiClient) def test_set_ticket_custom_fields_rejects_container_without_id( - client: GlpiClient, + client: Any, ) -> None: """A matched container with no ``id`` raises before any write. @@ -374,7 +369,7 @@ def test_set_ticket_custom_fields_rejects_container_without_id( assert isinstance(excinfo.value, ValueError) -def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> None: +def test_set_ticket_custom_fields_rejects_unknown_field(client: Any) -> None: """A typo in the field name raises before any write. ``GlpiValidationError`` inherits ``ValueError`` so existing callers that @@ -400,7 +395,7 @@ def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> N def test_set_ticket_custom_fields_with_empty_mapping_is_noop( - client: GlpiClient, + client: Any, ) -> None: """Passing an empty mapping performs no HTTP call.""" diff --git a/glpi_python_client/_sync/clients/client.py b/glpi_python_client/_sync/clients/client.py index 2164daa..fdee02c 100644 --- a/glpi_python_client/_sync/clients/client.py +++ b/glpi_python_client/_sync/clients/client.py @@ -105,7 +105,7 @@ def __enter__(self) -> Self: Returns ------- - AsyncGlpiClient + Self The client itself, suitable for chaining method calls. """ diff --git a/glpi_python_client/_sync/clients/commons/_config.py b/glpi_python_client/_sync/clients/commons/_config.py index 553f058..b611cc3 100644 --- a/glpi_python_client/_sync/clients/commons/_config.py +++ b/glpi_python_client/_sync/clients/commons/_config.py @@ -43,7 +43,7 @@ def __call__(self, *, verify_ssl: bool) -> httpx.Client: @dataclass(frozen=True) class ClientResources: - """Runtime resources owned by one async ``GlpiClient`` instance. + """Runtime resources owned by one client instance. The bundle keeps shared HTTP session, token manager, and optional v1 upload session tied together so the client can release them as a unit. @@ -85,8 +85,9 @@ def build_http_session(*, verify_ssl: bool) -> httpx.Client: Returns ------- - httpx.AsyncClient - A client configured for the requested SSL policy. + httpx.Client | httpx.AsyncClient + A client configured for the requested SSL policy -- the httpx + client matching this surface, as named in the signature. """ return httpx.Client( @@ -111,7 +112,7 @@ def build_client_resources( v1_app_token: str | None, session_factory: SessionFactory | None = None, ) -> ClientResources: - """Build the shared resources required by one async client instance. + """Build the shared resources required by one client instance. The helper validates the API URL, configures SSL behaviour, builds the OAuth token manager, and optionally instantiates the legacy v1 session diff --git a/glpi_python_client/_sync/clients/commons/tests/__init__.py b/glpi_python_client/_sync/clients/commons/tests/__init__.py new file mode 100644 index 0000000..a2432b6 --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the shared client building blocks.""" diff --git a/glpi_python_client/tests/clients/test_glpi_client.py b/glpi_python_client/_sync/clients/commons/tests/test_config.py similarity index 51% rename from glpi_python_client/tests/clients/test_glpi_client.py rename to glpi_python_client/_sync/clients/commons/tests/test_config.py index 8ca2d60..58884ae 100644 --- a/glpi_python_client/tests/clients/test_glpi_client.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_config.py @@ -1,13 +1,15 @@ -"""Unit tests for client setup, configuration, and lifecycle helpers.""" +"""Unit tests for client configuration parsing and validation. -from __future__ import annotations +These helpers normalise the API URL, validate the legacy v1 credential +pair, and coerce the GLPI_* environment variables. They are pure +functions: no client is constructed and nothing is awaited. +""" -import os -from typing import Any +from __future__ import annotations import pytest -from glpi_python_client import GlpiClient, GlpiValidationError +from glpi_python_client import GlpiValidationError from glpi_python_client._sync.clients.commons._config import ( build_client_env_config, normalize_client_api_url, @@ -158,154 +160,3 @@ def test_build_client_env_config_overrides_win() -> None: assert config["glpi_api_url"] == "https://override" assert config["verify_ssl"] is False assert config["language"] == "en_GB" - - -def test_glpi_client_from_env_uses_overrides_and_defaults( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``GlpiClient.from_env`` resolves env vars and applies overrides.""" - - env = { - "GLPI_API_URL": "https://glpi.example.test/api.php/v2", - "GLPI_USERNAME": "u", - "GLPI_PASSWORD": "p", - } - client = GlpiClient.from_env(env=env) - try: - assert client.glpi_api_url.endswith("/api.php/v2") - finally: - client.close() - - -def test_glpi_client_close_is_idempotent() -> None: - """Calling ``close`` twice does not raise.""" - - client = GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - ) - client.close() - client.close() - - -def test_glpi_client_async_context_manager() -> None: - """Using ``async with`` closes the client on exit.""" - - with GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - ) as client: - assert client.glpi_api_url.endswith("/api.php/v2") - # After __aexit__ the client is closed and rejects further calls. - with pytest.raises(RuntimeError, match="closed"): - client._ensure_token() - - -def test_glpi_client_rejects_invalid_credentials() -> None: - """Constructor refuses to build a client with no usable credentials.""" - - with pytest.raises(ValueError): - GlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2") - - -def test_glpi_client_v1_session_built_when_configured() -> None: - """Providing v1_base_url + v1_user_token instantiates the v1 session.""" - - client = GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - v1_base_url="https://glpi.example.test/apirest.php", - v1_user_token="user-token", - v1_app_token="app-token", - ) - try: - assert client._v1 is not None - finally: - client.close() - - -def test_glpi_client_rejects_partial_v1_config() -> None: - """Half-configured v1 values raise at construction time.""" - - with pytest.raises(ValueError, match="v1_base_url and v1_user_token"): - GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - v1_base_url="https://glpi.example.test/apirest.php", - ) - - -def test_environ_default_is_used_when_env_argument_omitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When no env mapping is provided ``os.environ`` is used.""" - - monkeypatch.setenv("GLPI_API_URL", "https://from-environ.example/api.php/v2") - monkeypatch.setenv("GLPI_USERNAME", "u") - monkeypatch.setenv("GLPI_PASSWORD", "p") - client = GlpiClient.from_env() - try: - assert client.glpi_api_url.endswith("/api.php/v2") - finally: - client.close() - - -def test_async_transport_ensure_open_blocks_after_close() -> None: - """Closed clients raise on subsequent transport calls.""" - - client = GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - ) - client.close() - with pytest.raises(RuntimeError, match="closed"): - client._ensure_open() - - -def test_glpi_client_init_failure_creates_no_session( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A bad credential set is rejected before any session is constructed. - - This used to build the session first and unwind it from an ``except`` - clause. That shape is not expressible on the async surface -- an - ``httpx.AsyncClient`` has no synchronous close and a constructor cannot - await one -- so the configuration is now validated up front instead. - - Asserting *nothing was built* is the stronger property: there is no - window in which a session exists but the client does not, so there is - nothing that can leak if the unwind is ever missed. - """ - - import httpx - - constructed: list[object] = [] - original_init = httpx.Client.__init__ - - def _track_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None: - constructed.append(self) - original_init(self, *args, **kwargs) - - monkeypatch.setattr(httpx.Client, "__init__", _track_init) - with pytest.raises(ValueError): - GlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - client_id="only-id-no-secret", - ) - assert constructed == [], "a transport session was built for a rejected config" - - -def test_no_other_vars_leak_into_environ_test() -> None: - """Sanity check that environment unset values stay None.""" - - config = build_client_env_config( - prefix="GLPI_", - env={k: v for k, v in os.environ.items() if not k.startswith("GLPI_")}, - overrides={}, - ) - assert config["glpi_api_url"] is None diff --git a/glpi_python_client/tests/commons/test_constants.py b/glpi_python_client/_sync/clients/commons/tests/test_constants.py similarity index 88% rename from glpi_python_client/tests/commons/test_constants.py rename to glpi_python_client/_sync/clients/commons/tests/test_constants.py index 7e337c3..0da4742 100644 --- a/glpi_python_client/tests/commons/test_constants.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_constants.py @@ -1,4 +1,4 @@ -"""Tests for the GLPI Knowledge base endpoint path constants.""" +"""Unit tests for the endpoint constant table.""" from __future__ import annotations diff --git a/glpi_python_client/tests/commons/test_filters.py b/glpi_python_client/_sync/clients/commons/tests/test_filters.py similarity index 100% rename from glpi_python_client/tests/commons/test_filters.py rename to glpi_python_client/_sync/clients/commons/tests/test_filters.py diff --git a/glpi_python_client/tests/commons/test_http.py b/glpi_python_client/_sync/clients/commons/tests/test_http.py similarity index 89% rename from glpi_python_client/tests/commons/test_http.py rename to glpi_python_client/_sync/clients/commons/tests/test_http.py index 0774e65..b34fec1 100644 --- a/glpi_python_client/tests/commons/test_http.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_http.py @@ -1,7 +1,7 @@ """Unit tests for :mod:`glpi_python_client._sync.clients.commons._http`. The tests cover the small request and response helper utilities used by the -asynchronous transport and the per-endpoint mixins. +transport and the per-endpoint mixins. """ from __future__ import annotations @@ -10,7 +10,7 @@ import pytest -from glpi_python_client import GlpiProtocolError, GlpiValidationError +from glpi_python_client import GlpiNotFoundError, GlpiProtocolError, GlpiValidationError from glpi_python_client._sync.clients.commons._http import ( build_request_headers, build_request_url, @@ -179,3 +179,19 @@ def test_protocol_error_is_not_a_validation_error() -> None: """A server-shape fault must not masquerade as a caller mistake.""" assert not issubclass(GlpiProtocolError, GlpiValidationError) + + +def test_4xx_raises_a_typed_status_error_from_ensure_response_status() -> None: + """The 4xx raise stays in ``ensure_response_status`` and is typed.""" + + response = FakeResponse(status_code=404, payload={}, text="nope") + with pytest.raises(GlpiNotFoundError) as excinfo: + ensure_response_status( + response, + success_statuses=(200, 206), + failure_message="Failed to fetch ticket 1", + ) + + assert excinfo.value.status_code == 404 + assert isinstance(excinfo.value, ValueError) + assert str(excinfo.value) == "Failed to fetch ticket 1: 404 nope" diff --git a/glpi_python_client/tests/commons/test_payloads.py b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py similarity index 100% rename from glpi_python_client/tests/commons/test_payloads.py rename to glpi_python_client/_sync/clients/commons/tests/test_payloads.py diff --git a/glpi_python_client/_sync/clients/commons/tests/test_transport.py b/glpi_python_client/_sync/clients/commons/tests/test_transport.py new file mode 100644 index 0000000..c1c24c8 --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/tests/test_transport.py @@ -0,0 +1,418 @@ +"""Unit tests for the GLPI transport mixin. + +The tests exercise the core dispatch path -- ``_ensure_token``, +``_send_request``, ``_execute_request``, and the four HTTP-verb helpers -- +using a real client with its session and auth stubbed out so no real +network call is made. + +Retry semantics for the v2 transport are folded in here too: 5xx is retried, +4xx is not. These tests are the regression net for the retry predicate. +Getting the predicate wrong disables retries silently -- nothing raises, +nothing fails, requests simply stop being retried. See the 0.4.0 plan-1 +notes. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import httpx +import pytest +from tenacity import wait_fixed + +from glpi_python_client import ( + GlpiClient, + GlpiError, + GlpiServerError, + GlpiTimeoutError, + GlpiTransportError, +) +from glpi_python_client._sync._testing import make_client +from glpi_python_client.testing.utils import FakeResponse + +_RETRIED_METHODS = ( + "_get_request", + "_post_request", + "_update_request", + "_delete_request", +) + + +@pytest.fixture +def transport_client() -> Iterator[Any]: + """Yield a client with auth and send_request pre-stubbed.""" + + c = make_client() + # Inject a ready access token and make ensure_token a no-op so the + # transport helpers can be called without network access. + c._auth.access_token = "test-token" + + def _ensure_token() -> None: + return None + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + + # Stub _send_request at the seam level so _execute_request exercises the + # real header-building logic while returning a controlled response. + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload={"id": 1}) + + c._send_request = _send # type: ignore[method-assign] + yield c + c.close() + + +def test_ensure_token_calls_auth_manager(monkeypatch: pytest.MonkeyPatch) -> None: + """``_ensure_token`` invokes ``_auth.ensure_token`` on an open client.""" + + c = make_client() + called: list[bool] = [] + + def _ensure_token() -> None: + called.append(True) + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + c._ensure_token() + assert called + c.close() + + +def test_send_request_dispatches_through_session_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_send_request`` routes through ``session.request`` with an upper verb. + + The verb is passed as a *value* to ``request`` rather than being looked + up as a per-verb attribute: that is the one call shape ``requests`` and + ``httpx`` share, so the transport swap does not have to reason about + dynamic attribute lookup. + """ + + c = make_client() + fake = FakeResponse(status_code=200, payload={}) + seen: dict[str, object] = {} + + def _request(method: str, url: str, **kw: object) -> FakeResponse: + seen.update({"method": method, "url": url, "kw": kw}) + return fake + + monkeypatch.setattr(c._session, "request", _request) + result = c._send_request( + "get", "https://glpi.example.test/api.php/v2/test", timeout=30 + ) + assert result is fake + assert seen["method"] == "GET" + assert seen["url"] == "https://glpi.example.test/api.php/v2/test" + assert seen["kw"] == {"timeout": 30} + c.close() + + +def test_send_request_does_not_use_per_verb_attributes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stubbing only ``session.get`` must no longer intercept a GET. + + This pins the seam: if dispatch ever regresses to + ``getattr(session, verb)`` this test fails, because the per-verb + attribute would be used again. + """ + + c = make_client() + sentinel = FakeResponse(status_code=418, payload={}) + monkeypatch.setattr(c._session, "get", lambda url, **kw: sentinel) + captured: dict[str, object] = {} + + def _request(method: str, url: str, **kw: object) -> FakeResponse: + captured["used"] = True + return FakeResponse(status_code=200, payload={}) + + monkeypatch.setattr(c._session, "request", _request) + result = c._send_request("get", "https://glpi.example.test/api.php/v2/test") + assert captured.get("used") is True + assert result is not sentinel + c.close() + + +def test_execute_request_get_builds_params(transport_client: Any) -> None: + """``_execute_request`` places query params on GET requests.""" + + captured: dict[str, Any] = {} + + def _capture(method: str, url: str, **kw: Any) -> FakeResponse: + captured.update({"method": method, "url": url, "kw": kw}) + return FakeResponse(status_code=200, payload={}) + + transport_client._send_request = _capture # type: ignore[method-assign] + transport_client._execute_request( + method="get", + endpoint="Assistance/Ticket", + success_statuses=(200,), + params={"range": "0-49"}, + ) + assert captured["method"] == "get" + assert "Assistance/Ticket" in captured["url"] + assert "params" in captured["kw"] + + +def test_execute_request_post_builds_json_body(transport_client: Any) -> None: + """``_execute_request`` places the body in ``json`` for non-GET verbs.""" + + captured: dict[str, Any] = {} + + def _capture(method: str, url: str, **kw: Any) -> FakeResponse: + captured.update({"method": method, "kw": kw}) + return FakeResponse(status_code=201, payload={}) + + transport_client._send_request = _capture # type: ignore[method-assign] + transport_client._execute_request( + method="post", + endpoint="Assistance/Ticket", + success_statuses=(201,), + json_body={"name": "t"}, + include_content_type=True, + ) + assert captured["method"] == "post" + assert captured["kw"].get("json") == {"name": "t"} + + +def test_get_request_returns_response(transport_client: Any) -> None: + """``_get_request`` dispatches via ``_execute_request`` and returns the response.""" + + resp = transport_client._get_request("Assistance/Ticket") + assert resp.status_code == 200 + + +def test_post_request_returns_response(transport_client: Any) -> None: + """``_post_request`` dispatches and returns the response.""" + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=201, payload={"id": 99}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = transport_client._post_request( + "Assistance/Ticket", json_body={"name": "t"} + ) + assert resp.status_code == 201 + + +def test_update_request_returns_response(transport_client: Any) -> None: + """``_update_request`` dispatches and returns the response.""" + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=200, payload={}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = transport_client._update_request( + "Assistance/Ticket/1", json_body={"name": "u"} + ) + assert resp.status_code == 200 + + +def test_delete_request_returns_response(transport_client: Any) -> None: + """``_delete_request`` dispatches and returns the response.""" + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=204, payload={}) + + transport_client._send_request = _send # type: ignore[method-assign] + resp = transport_client._delete_request("Assistance/Ticket/1") + assert resp.status_code == 204 + + +# --- Retry semantics ------------------------------------------------------- +# +# These tests need a client whose ``_send_request`` has *not* been +# pre-stubbed at the instance level: several of them replace +# ``_session.request`` instead, one seam lower, and rely on the real +# ``_send_request`` bound method to route through it. If ``_send_request`` +# were already overridden by ``transport_client`` above, that override would +# shadow the real method and the ``_session.request`` replacement would +# never be exercised. Hence a second, lighter fixture (``retry_client``) +# rather than reusing ``transport_client``. + + +@pytest.fixture(autouse=True) +def _no_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """Drop the 3s fixed wait so retry tests stay instant. + + The decorator's ``Retrying`` object is patched directly. Patching + ``tenacity.nap.time.sleep`` would work today but silently stops working + on the async path, so it is deliberately not used here. + """ + + for name in _RETRIED_METHODS: + monkeypatch.setattr(getattr(GlpiClient, name).retry, "wait", wait_fixed(0)) + + +@pytest.fixture +def retry_client() -> Iterator[Any]: + """Return a client with auth stubbed so no token call is made.""" + + c = make_client() + c._auth.access_token = "test-token" + + def _ensure_token() -> None: + return None + + c._auth.ensure_token = _ensure_token # type: ignore[method-assign] + yield c + c.close() + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +def test_5xx_is_retried_three_times_and_reraises_server_error( + retry_client: Any, method_name: str +) -> None: + """A persistent 5xx costs 3 attempts and surfaces as ``GlpiServerError``. + + Parametrized across all four retried verbs (``_get_request``, + ``_post_request``, ``_update_request``, ``_delete_request``): they share + the same decorator, but before this test only ``_get_request``'s attempt + count was pinned. + """ + + attempts: list[int] = [] + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + return FakeResponse( + status_code=500, payload={}, text="boom", reason="Server Error" + ) + + retry_client._send_request = _send # type: ignore[method-assign] + with pytest.raises(GlpiServerError) as excinfo: + getattr(retry_client, method_name)("Assistance/Ticket") + + assert len(attempts) == 3 + assert excinfo.value.status_code == 500 + assert excinfo.value.url == "https://glpi.example.test/api.php/Assistance/Ticket" + + +def test_persistent_5xx_does_not_surface_as_retry_error( + retry_client: Any, +) -> None: + """``reraise=True``: callers see the real error, never ``tenacity.RetryError``.""" + + import tenacity + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse( + status_code=503, payload={}, text="down", reason="Service Unavailable" + ) + + retry_client._send_request = _send # type: ignore[method-assign] + with pytest.raises(GlpiServerError) as excinfo: + retry_client._get_request("Assistance/Ticket") + assert not isinstance(excinfo.value, tenacity.RetryError) + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +def test_4xx_is_not_retried_by_the_transport( + retry_client: Any, method_name: str +) -> None: + """A 4xx is logged and returned by ``finalize_request_response``, not retried. + + Parametrized across all four retried verbs so a predicate regression + that starts retrying 4xx on any single verb fails loudly. + """ + + attempts: list[int] = [] + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + return FakeResponse(status_code=404, payload={}, text="nope") + + retry_client._send_request = _send # type: ignore[method-assign] + response = getattr(retry_client, method_name)("Assistance/Ticket/1") + + assert len(attempts) == 1 + assert response.status_code == 404 + + +def test_tolerant_search_still_returns_empty_on_4xx(retry_client: Any) -> None: + """Search endpoints that pass no ``failure_message`` still swallow a 4xx. + + Guards the 7 tolerant ``_resource_list`` call sites against the 4xx raise + being moved into ``finalize_request_response``. + """ + + def _send(method: str, url: str, **kw: Any) -> FakeResponse: + return FakeResponse(status_code=400, payload=[], text="[]") + + retry_client._send_request = _send # type: ignore[method-assign] + assert retry_client.search_tickets() == [] + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +def test_network_errors_are_still_retried( + retry_client: Any, method_name: str +) -> None: + """Real transport faults are translated and still retried three times. + + The fault is injected at ``session.request`` -- *below* the translation + boundary -- rather than by stubbing ``_send_request``. That matters: a stub + above the boundary would raise the HTTP library's own exception, which the + retry predicate no longer names, so the test would pass or fail for + reasons unrelated to the behaviour it is meant to pin. Injecting here + exercises the real path end to end: a genuine ``httpx`` fault, translated + into ``GlpiTransportError``, matched by the predicate, retried three + times, and surfaced to the caller as a library error. + + Parametrized across all four retried verbs so the network-fault attempt + count is pinned for each, not just ``_get_request``. + """ + + attempts: list[int] = [] + + def _request(method: str, url: str, **kw: Any) -> FakeResponse: + attempts.append(1) + raise httpx.ConnectError("network down") + + retry_client._session.request = _request + with pytest.raises(GlpiTransportError): + getattr(retry_client, method_name)("Assistance/Ticket") + + assert len(attempts) == 3 + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +def test_no_third_party_exception_reaches_the_caller( + retry_client: Any, method_name: str +) -> None: + """A network fault never surfaces as the HTTP library's own exception. + + The public contract is that ``GlpiError`` is sufficient to catch the + library's failures. This pins the half of that promise which used to be + false: transport faults escaped as third-party exceptions, forcing callers + to import the HTTP library. If the translation is ever removed, the raw + exception reaches the caller and this fails. + """ + + def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectError("network down") + + retry_client._session.request = _request + with pytest.raises(GlpiError) as excinfo: + getattr(retry_client, method_name)("Assistance/Ticket") + + assert not isinstance(excinfo.value, httpx.HTTPError) + # The original fault stays reachable for debugging. + assert isinstance(excinfo.value.__cause__, httpx.ConnectError) + + +def test_timeouts_narrow_to_the_timeout_subclass(retry_client: Any) -> None: + """A timeout surfaces as ``GlpiTimeoutError``, not just the base class. + + ``GlpiTimeoutError`` exists so callers can single out the "GLPI was too + slow" case from "GLPI was unreachable". That only works if the translation + actually inspects the fault type rather than flattening everything to the + base class. + """ + + def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectTimeout("too slow") + + retry_client._session.request = _request + with pytest.raises(GlpiTimeoutError): + retry_client._get_request("Assistance/Ticket") diff --git a/glpi_python_client/_sync/clients/custom/__init__.py b/glpi_python_client/_sync/clients/custom/__init__.py index b590325..551f8cb 100644 --- a/glpi_python_client/_sync/clients/custom/__init__.py +++ b/glpi_python_client/_sync/clients/custom/__init__.py @@ -8,8 +8,8 @@ Each helper is written once. The fan-out points call ``gather`` from :mod:`glpi_python_client._sync._concurrency`, which runs them -concurrently here and sequentially in the generated tree -- so there is -no second copy of this logic to keep in step. +concurrently on the async surface and sequentially on the generated one +-- so there is no second copy of this logic to keep in step. """ from __future__ import annotations diff --git a/glpi_python_client/_sync/clients/custom/tests/__init__.py b/glpi_python_client/_sync/clients/custom/tests/__init__.py new file mode 100644 index 0000000..58fbdf4 --- /dev/null +++ b/glpi_python_client/_sync/clients/custom/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the aggregating helpers built on the API mixins.""" diff --git a/glpi_python_client/tests/custom/test_statistics.py b/glpi_python_client/_sync/clients/custom/tests/test_statistics.py similarity index 91% rename from glpi_python_client/tests/custom/test_statistics.py rename to glpi_python_client/_sync/clients/custom/tests/test_statistics.py index 9d3037e..6939b83 100644 --- a/glpi_python_client/tests/custom/test_statistics.py +++ b/glpi_python_client/_sync/clients/custom/tests/test_statistics.py @@ -1,8 +1,8 @@ -"""Unit tests for the asynchronous statistics helpers. +"""Unit tests for the statistics helpers. -The tests stub the API methods on a real :class:`GlpiClient` so the -statistics aggregations exercise their real summarization logic without any -network call. +The tests stub the API methods on a real client so the statistics +aggregations exercise their real summarization logic without any network +call. """ from __future__ import annotations @@ -13,7 +13,6 @@ import pytest from glpi_python_client import ( - GlpiClient, GlpiPriority, GlpiTicketStatus, GlpiTicketType, @@ -31,7 +30,6 @@ from glpi_python_client.models.api_schema.assistance.timeline._task import ( GetTicketTask, ) -from glpi_python_client.testing.utils import make_client class _FakeV1Search: @@ -68,12 +66,8 @@ def request_json( rows = [{"2": ticket_id} for ticket_id in sorted(set(matched))] return {"totalcount": len(rows), "data": rows} - -@pytest.fixture -def client() -> GlpiClient: - """Return one in-memory test client.""" - - return make_client() + def close(self) -> None: + """No-op; the real session is closed with the client.""" def _ticket( @@ -104,7 +98,7 @@ def _ticket( def test_get_ticket_statistics_aggregates_by_entity_status_priority_type( - client: GlpiClient, + client: Any, ) -> None: """All aggregation buckets are produced from the search response.""" @@ -153,7 +147,7 @@ def fake_search( def test_get_ticket_statistics_default_window_uses_today( - client: GlpiClient, + client: Any, ) -> None: """When no dates are passed the helper uses today minus default_days.""" @@ -173,7 +167,7 @@ def fake_search( assert f"date_creation=le={end.isoformat()} 23:59:59" in captured["filter"] -def test_get_ticket_statistics_rejects_invalid_window(client: GlpiClient) -> None: +def test_get_ticket_statistics_rejects_invalid_window(client: Any) -> None: """Invalid date inputs raise locally before any HTTP request. ``GlpiValidationError`` inherits ``ValueError`` so existing callers that @@ -184,12 +178,14 @@ def test_get_ticket_statistics_rejects_invalid_window(client: GlpiClient) -> Non client.get_ticket_statistics(default_days=0) assert isinstance(exc1.value, ValueError) with pytest.raises(GlpiValidationError, match="start_date") as exc2: - client.get_ticket_statistics(start_date="2026-02-01", end_date="2026-01-01") + client.get_ticket_statistics( + start_date="2026-02-01", end_date="2026-01-01" + ) assert isinstance(exc2.value, ValueError) def test_get_ticket_statistics_rejects_malformed_iso_date( - client: GlpiClient, + client: Any, ) -> None: """A malformed ISO date string raises ``GlpiValidationError``, not a bare ``date.fromisoformat`` ``ValueError``. @@ -201,12 +197,14 @@ def test_get_ticket_statistics_rejects_malformed_iso_date( """ with pytest.raises(GlpiValidationError, match="start_date") as excinfo: - client.get_ticket_statistics(start_date="2026-13-45", end_date="2026-01-31") + client.get_ticket_statistics( + start_date="2026-13-45", end_date="2026-01-31" + ) assert isinstance(excinfo.value, ValueError) assert isinstance(excinfo.value.__cause__, ValueError) -def test_get_task_statistics_zero_for_empty_input(client: GlpiClient) -> None: +def test_get_task_statistics_zero_for_empty_input(client: Any) -> None: """An empty ticket list returns zeroed totals without any HTTP call.""" result = client.get_task_statistics([]) @@ -220,7 +218,7 @@ def test_get_task_statistics_zero_for_empty_input(client: GlpiClient) -> None: def test_get_task_statistics_aggregates_by_user_and_ticket( - client: GlpiClient, + client: Any, ) -> None: """Durations group by user and parent ticket.""" @@ -263,7 +261,7 @@ def fake_list(ticket_id: int) -> list[GetTicketTask]: # --------------------------------------------------------------------------- -def test_get_ticket_statistics_entity_id_filter(client: GlpiClient) -> None: +def test_get_ticket_statistics_entity_id_filter(client: Any) -> None: """When entity_id is given its RSQL clause is appended to the filter.""" captured: dict[str, Any] = {} @@ -288,7 +286,7 @@ def fake_search( assert "is_deleted==false" in captured["filter"] -def test_get_ticket_statistics_entity_name_resolution(client: GlpiClient) -> None: +def test_get_ticket_statistics_entity_name_resolution(client: Any) -> None: """When entity_name is given entities are resolved and IDs ORed.""" from glpi_python_client.models.api_schema.administration._entity import GetEntity @@ -321,7 +319,7 @@ def fake_search( assert "(entity.id==3,entity.id==4)" in captured_tickets["filter"] -def test_get_ticket_statistics_entity_name_no_match(client: GlpiClient) -> None: +def test_get_ticket_statistics_entity_name_no_match(client: Any) -> None: """When entity_name matches nothing the fast-path returns empty entities.""" from glpi_python_client.models.api_schema.administration._entity import GetEntity @@ -340,7 +338,7 @@ def fake_search_entities( assert result == {"entities": {}} -def test_get_ticket_statistics_extra_filter_appended(client: GlpiClient) -> None: +def test_get_ticket_statistics_extra_filter_appended(client: Any) -> None: """extra_filter is AND-joined with the date window.""" captured: dict[str, Any] = {} @@ -361,7 +359,7 @@ def fake_search( assert "priority==5" in captured["filter"] -def test_get_ticket_statistics_default_days_window(client: GlpiClient) -> None: +def test_get_ticket_statistics_default_days_window(client: Any) -> None: """default_days shifts the start of the window without other params.""" from datetime import date, timedelta @@ -398,21 +396,24 @@ def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket: return GetTicket(**payload) -def test_get_task_durations_empty_ticket_list(client: GlpiClient) -> None: +def test_get_task_durations_empty_ticket_list(client: Any) -> None: """When no tickets match, all durations are zero.""" def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): - return iter([]) + for batch in []: + yield batch client.iter_search_tickets = fake_iter # type: ignore[method-assign] - result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + result = client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) assert result["total_duration"] == 0 assert result["task_count"] == 0 assert result["duration_by_entity"] == {} assert result["tasks"] is None -def test_get_task_durations_entity_grouping(client: GlpiClient) -> None: +def test_get_task_durations_entity_grouping(client: Any) -> None: """duration_by_entity groups ticket-task durations by entity key.""" tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)] @@ -431,11 +432,13 @@ def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: client.iter_search_tickets = fake_iter # type: ignore[method-assign] client.get_task_statistics = fake_task_stats # type: ignore[method-assign] - result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + result = client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) assert result["duration_by_entity"] == {"10": 600, "20": 600} -def test_get_task_durations_return_task_details_true(client: GlpiClient) -> None: +def test_get_task_durations_return_task_details_true(client: Any) -> None: """When return_task_details=True a tasks list with correct shape is returned.""" tickets = [_make_ticket(1, entity_id=10)] @@ -477,14 +480,17 @@ def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]: assert task["user_id"] == 7 -def test_get_task_durations_return_task_details_false(client: GlpiClient) -> None: +def test_get_task_durations_return_task_details_false(client: Any) -> None: """When return_task_details=False the tasks key is None.""" def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): - return iter([]) + for batch in []: + yield batch client.iter_search_tickets = fake_iter # type: ignore[method-assign] - result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + result = client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) assert result["tasks"] is None @@ -493,7 +499,7 @@ def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): # --------------------------------------------------------------------------- -def test_get_user_activity_raises_without_identifier(client: GlpiClient) -> None: +def test_get_user_activity_raises_without_identifier(client: Any) -> None: """Calling without any identifier raises ``GlpiValidationError``. ``GlpiValidationError`` inherits ``ValueError`` so existing callers that @@ -505,7 +511,7 @@ def test_get_user_activity_raises_without_identifier(client: GlpiClient) -> None assert isinstance(excinfo.value, ValueError) -def test_get_user_activity_single_user_happy_path(client: GlpiClient) -> None: +def test_get_user_activity_single_user_happy_path(client: Any) -> None: """A single matched user populates all activity keys.""" from glpi_python_client.models.api_schema.administration._user import GetUser @@ -577,7 +583,7 @@ def fake_task_durations( assert "users_id_requester" not in window_calls[0] -def test_get_user_activity_raises_when_no_users_matched(client: GlpiClient) -> None: +def test_get_user_activity_raises_when_no_users_matched(client: Any) -> None: """When search_users returns empty a ``GlpiValidationError`` is raised. ``GlpiValidationError`` inherits ``ValueError`` so existing callers that @@ -601,7 +607,7 @@ def fake_search_users( assert isinstance(excinfo.value, ValueError) -def test_get_user_activity_multi_user_merge(client: GlpiClient) -> None: +def test_get_user_activity_multi_user_merge(client: Any) -> None: """Multiple users under the same display key have their counts merged.""" from glpi_python_client.models.api_schema.administration._user import GetUser @@ -700,7 +706,7 @@ def _task_row( } -def test_v1_task_statistics_matches_the_per_ticket_shape(client: GlpiClient) -> None: +def test_v1_task_statistics_matches_the_per_ticket_shape(client: Any) -> None: """The bulk v1 sweep produces the same aggregate as the per-ticket path. v1 ``actiontime`` is v2 ``duration`` and v1 ``users_id`` is the v2 task @@ -727,7 +733,7 @@ def test_v1_task_statistics_matches_the_per_ticket_shape(client: GlpiClient) -> def test_v1_task_statistics_stops_paging_before_the_window( - client: GlpiClient, + client: Any, ) -> None: """Paging stops once a page predates the window start. @@ -750,7 +756,7 @@ def test_v1_task_statistics_stops_paging_before_the_window( def test_get_task_durations_uses_per_ticket_path_below_threshold( - client: GlpiClient, + client: Any, ) -> None: """A small ticket set keeps the v2 per-ticket path and needs no v1.""" @@ -772,7 +778,9 @@ def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: client.iter_search_tickets = fake_iter # type: ignore[method-assign] client.get_task_statistics = fake_task_stats # type: ignore[method-assign] # No v1 session configured at all: the small-set path must not need one. - result = client.get_task_durations(start_date="2026-07-01", end_date="2026-07-31") + result = client.get_task_durations( + start_date="2026-07-01", end_date="2026-07-31" + ) assert result["task_count"] == 0 assert calls == [[1]] diff --git a/glpi_python_client/tests/custom/test_ticket_context.py b/glpi_python_client/_sync/clients/custom/tests/test_ticket_context.py similarity index 89% rename from glpi_python_client/tests/custom/test_ticket_context.py rename to glpi_python_client/_sync/clients/custom/tests/test_ticket_context.py index cc4694a..a4a197f 100644 --- a/glpi_python_client/tests/custom/test_ticket_context.py +++ b/glpi_python_client/_sync/clients/custom/tests/test_ticket_context.py @@ -1,6 +1,6 @@ -"""Unit tests for the synchronous ticket-context mixin. +"""Unit tests for the ticket-context mixin. -The tests stub ``_get_request`` on a real :class:`GlpiClient` so +The tests stub ``_get_request`` on a real client so :meth:`~glpi_python_client._sync.clients.custom._ticket_context.TicketContextMixin.get_ticket_context` exercises its real aggregation logic without any network call. """ @@ -9,7 +9,8 @@ from typing import Any -from glpi_python_client.testing.utils import FakeResponse, make_client +from glpi_python_client._sync._testing import make_client +from glpi_python_client.testing.utils import FakeResponse def test_get_ticket_context_assembles_ticket_and_timeline() -> None: diff --git a/glpi_python_client/_sync/clients/tests/__init__.py b/glpi_python_client/_sync/clients/tests/__init__.py new file mode 100644 index 0000000..aeb0b4a --- /dev/null +++ b/glpi_python_client/_sync/clients/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for client construction, configuration and lifecycle.""" diff --git a/glpi_python_client/_sync/clients/tests/test_client.py b/glpi_python_client/_sync/clients/tests/test_client.py new file mode 100644 index 0000000..70624f8 --- /dev/null +++ b/glpi_python_client/_sync/clients/tests/test_client.py @@ -0,0 +1,165 @@ +"""Unit tests for client construction and lifecycle helpers.""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +from glpi_python_client import GlpiClient +from glpi_python_client._sync._testing import make_client +from glpi_python_client._sync.clients.commons._config import build_client_env_config + + +def test_glpi_client_from_env_uses_overrides_and_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from_env`` resolves env vars and applies overrides.""" + + env = { + "GLPI_API_URL": "https://glpi.example.test/api.php/v2", + "GLPI_USERNAME": "u", + "GLPI_PASSWORD": "p", + } + client = GlpiClient.from_env(env=env) + try: + assert client.glpi_api_url.endswith("/api.php/v2") + finally: + client.close() + + +def test_glpi_client_close_is_idempotent() -> None: + """Calling ``close`` twice does not raise.""" + + client = GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + ) + client.close() + client.close() + + +def test_glpi_client_async_context_manager() -> None: + """The context manager closes the client on exit.""" + + with make_client() as c: + assert c._session is not None + assert c._closed is True + # Closing must be observable, not just recorded on a private flag: + # every transport helper goes through _ensure_open, so the first one + # reached after the block has to refuse. Match the guard's own message, + # not the bare word "closed" -- with the guard disabled the call still + # raises RuntimeError, but from httpx ("Cannot send a request, as the + # client has been closed."), so the looser pattern passes either way. + with pytest.raises(RuntimeError, match="GLPI client is closed"): + c._ensure_token() + + +def test_glpi_client_rejects_invalid_credentials() -> None: + """Constructor refuses to build a client with no usable credentials.""" + + with pytest.raises(ValueError): + GlpiClient(glpi_api_url="https://glpi.example.test/api.php/v2") + + +def test_glpi_client_v1_session_built_when_configured() -> None: + """Providing v1_base_url + v1_user_token instantiates the v1 session.""" + + client = GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + v1_base_url="https://glpi.example.test/apirest.php", + v1_user_token="user-token", + v1_app_token="app-token", + ) + try: + assert client._v1 is not None + finally: + client.close() + + +def test_glpi_client_rejects_partial_v1_config() -> None: + """Half-configured v1 values raise at construction time.""" + + with pytest.raises(ValueError, match="v1_base_url and v1_user_token"): + GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + v1_base_url="https://glpi.example.test/apirest.php", + ) + + +def test_environ_default_is_used_when_env_argument_omitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no env mapping is provided ``os.environ`` is used.""" + + monkeypatch.setenv("GLPI_API_URL", "https://from-environ.example/api.php/v2") + monkeypatch.setenv("GLPI_USERNAME", "u") + monkeypatch.setenv("GLPI_PASSWORD", "p") + client = GlpiClient.from_env() + try: + assert client.glpi_api_url.endswith("/api.php/v2") + finally: + client.close() + + +def test_async_transport_ensure_open_blocks_after_close() -> None: + """Closed clients raise on subsequent transport calls.""" + + client = GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + username="u", + password="p", + ) + client.close() + with pytest.raises(RuntimeError, match="closed"): + client._ensure_open() + + +def test_glpi_client_init_failure_creates_no_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bad credential set is rejected before any session is constructed. + + This used to build the session first and unwind it from an ``except`` + clause. That assumes a constructor can always close a partially built + session before it returns, which is not something every client variant + guarantees -- so the configuration is now validated up front instead. + + Asserting *nothing was built* is the stronger property: there is no + window in which a session exists but the client does not, so there is + nothing that can leak if the unwind is ever missed. + """ + + import httpx + + constructed: list[object] = [] + original_init = httpx.Client.__init__ + + def _track_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None: + constructed.append(self) + original_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.Client, "__init__", _track_init) + with pytest.raises(ValueError): + GlpiClient( + glpi_api_url="https://glpi.example.test/api.php/v2", + client_id="only-id-no-secret", + ) + assert constructed == [], "a transport session was built for a rejected config" + + +def test_no_other_vars_leak_into_environ_test() -> None: + """Sanity check that environment unset values stay None.""" + + config = build_client_env_config( + prefix="GLPI_", + env={k: v for k, v in os.environ.items() if not k.startswith("GLPI_")}, + overrides={}, + ) + assert config["glpi_api_url"] is None diff --git a/glpi_python_client/_sync/conftest.py b/glpi_python_client/_sync/conftest.py new file mode 100644 index 0000000..2a837d0 --- /dev/null +++ b/glpi_python_client/_sync/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures shared by every unit test in this tree. + +Generated into the sync tree alongside the modules it serves, so both +surfaces get a fixture that builds the client they actually test. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from glpi_python_client import GlpiClient +from glpi_python_client._sync._testing import make_client + + +@pytest.fixture +def client() -> Iterator[GlpiClient]: + """Yield a default in-memory client and close it afterwards.""" + + instance = make_client() + yield instance + instance.close() diff --git a/glpi_python_client/_sync/tests/__init__.py b/glpi_python_client/_sync/tests/__init__.py new file mode 100644 index 0000000..7fc9482 --- /dev/null +++ b/glpi_python_client/_sync/tests/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the hand-written concurrency primitives. + +``_concurrency`` is the one module maintained by hand on both sides, so +its tests are too: each surface asserts what is true of its own +primitives rather than a transformed copy of the other's. +""" diff --git a/glpi_python_client/_sync/tests/test_concurrency.py b/glpi_python_client/_sync/tests/test_concurrency.py new file mode 100644 index 0000000..0998c73 --- /dev/null +++ b/glpi_python_client/_sync/tests/test_concurrency.py @@ -0,0 +1,72 @@ +"""The sync concurrency twin behaves as the async one's counterpart. + +Hand-written on this side, like ``_concurrency.py`` itself. The async twin +asserts that ``gather`` overlaps work and that concurrent tasks contend the +auth lock; neither claim exists here. What does have to hold is that the +sequential ``gather`` keeps the contract callers actually rely on -- +results matched to arguments by position -- and that the lock is a real +mutual-exclusion primitive rather than a no-op stand-in. +""" + +from __future__ import annotations + +import threading +import time + +from glpi_python_client._sync._concurrency import Lock, gather + + +def test_gather_preserves_argument_order() -> None: + """Results come back positionally, never in completion order.""" + + assert gather("a", "b", "c") == ["a", "b", "c"] + + +def test_gather_of_nothing_is_empty() -> None: + """An empty fan-out is legal and yields an empty list.""" + + assert gather() == [] + + +def test_the_lock_is_a_real_threading_primitive() -> None: + """The auth lock actually excludes, so token acquisition cannot race.""" + + lock = Lock() + with lock: + assert not lock.acquire(blocking=False) + assert lock.acquire(blocking=False) + lock.release() + + +def test_the_lock_serialises_concurrent_threads() -> None: + """Two threads cannot hold the lock at once. + + The critical section holds briefly (``time.sleep``, not a bare + increment) so that if the lock did not exclude, several of the eight + threads would genuinely overlap inside it. Without that hold this test + passes even against a no-op stand-in: the body is so short that the GIL + and thread-startup scheduling alone keep the eight runs from ever + actually overlapping, so a broken lock and a real one are + indistinguishable. Confirmed empirically: a no-op lock fails this test + on every trial once the hold is added, and never fails without it. + """ + + lock = Lock() + overlaps: list[int] = [] + inside = 0 + + def _worker() -> None: + nonlocal inside + with lock: + inside += 1 + overlaps.append(inside) + time.sleep(0.02) + inside -= 1 + + threads = [threading.Thread(target=_worker) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert overlaps == [1] * 8 diff --git a/glpi_python_client/testing/__init__.py b/glpi_python_client/testing/__init__.py index c78ed0e..77d4d7a 100644 --- a/glpi_python_client/testing/__init__.py +++ b/glpi_python_client/testing/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from glpi_python_client.testing.utils import ( + DEFAULT_CLIENT_CONFIG, FakeResponse, SearchResponse, TicketResponse, @@ -15,6 +16,7 @@ ) __all__ = [ + "DEFAULT_CLIENT_CONFIG", "FakeResponse", "SearchResponse", "TicketResponse", diff --git a/glpi_python_client/testing/fixtures.py b/glpi_python_client/testing/fixtures.py index 8f4ecf4..1191886 100644 --- a/glpi_python_client/testing/fixtures.py +++ b/glpi_python_client/testing/fixtures.py @@ -2,6 +2,15 @@ These fixtures provide a reusable client factory for unit tests while delegating the concrete data construction to the shared testing utilities. + +**Requires pytest**, which is deliberately *not* a runtime dependency of +this package -- it is declared in the ``dev`` extra. This module ships so +downstream test suites can register it, and any suite doing so already has +pytest installed. Importing it from application code raises +``ModuleNotFoundError``; import :mod:`glpi_python_client.testing.utils` +instead, which has no test-framework dependency. Register the fixtures +with ``pytest_plugins = ("glpi_python_client.testing.fixtures",)`` in your +own ``conftest.py``. """ from __future__ import annotations diff --git a/glpi_python_client/testing/tests/__init__.py b/glpi_python_client/testing/tests/__init__.py new file mode 100644 index 0000000..2627690 --- /dev/null +++ b/glpi_python_client/testing/tests/__init__.py @@ -0,0 +1 @@ +"""Suites that span modules, plus repo-wide structural guards.""" diff --git a/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py b/glpi_python_client/testing/tests/test_content_roundtrip.py similarity index 100% rename from glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py rename to glpi_python_client/testing/tests/test_content_roundtrip.py diff --git a/glpi_python_client/tests/test_docstring_references.py b/glpi_python_client/testing/tests/test_docstring_references.py similarity index 98% rename from glpi_python_client/tests/test_docstring_references.py rename to glpi_python_client/testing/tests/test_docstring_references.py index aa059b7..676de0c 100644 --- a/glpi_python_client/tests/test_docstring_references.py +++ b/glpi_python_client/testing/tests/test_docstring_references.py @@ -19,7 +19,7 @@ import pathlib import re -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] _PACKAGE = _REPO_ROOT / "glpi_python_client" #: A qualified reference to this package: the root name plus at least one diff --git a/glpi_python_client/models/api_schema/knowledgebase/tests/test_exports.py b/glpi_python_client/testing/tests/test_exports.py similarity index 100% rename from glpi_python_client/models/api_schema/knowledgebase/tests/test_exports.py rename to glpi_python_client/testing/tests/test_exports.py diff --git a/glpi_python_client/tests/clients/test_method_invocation.py b/glpi_python_client/testing/tests/test_method_invocation.py similarity index 87% rename from glpi_python_client/tests/clients/test_method_invocation.py rename to glpi_python_client/testing/tests/test_method_invocation.py index 1fecef7..23cb896 100644 --- a/glpi_python_client/tests/clients/test_method_invocation.py +++ b/glpi_python_client/testing/tests/test_method_invocation.py @@ -238,6 +238,7 @@ def _build_call_args(method: Any) -> dict[str, Any] | None: SYNC_METHODS = _public_methods(GlpiClient) +ASYNC_METHODS = _public_methods(AsyncGlpiClient) def test_the_public_surface_has_not_silently_shrunk() -> None: @@ -302,26 +303,50 @@ def test_every_public_method_reaches_the_transport(method_name: str) -> None: client.close() -@pytest.mark.parametrize("method_name", ["get_ticket", "search_tickets", "get_user"]) +@pytest.mark.parametrize("method_name", ASYNC_METHODS) def test_async_methods_reach_the_transport(method_name: str) -> None: - """A representative async slice dispatches through the same seam. - - The bridge (and, after the codegen step, the generated async tree) must - reach the transport exactly as the sync client does. + """Each public async method dispatches an HTTP call. + + This mirrors the sync sweep rather than sampling it. Coverage is + measured on the ``_async`` tree, because that is the tree a person + edits -- so the async surface has to be driven as thoroughly as the + generated one, or the report credits the sync twin for work the source + never did. Sampling three methods here left five statements in + ``_async/`` covered only by their sync counterparts. """ - async def _run() -> list[str]: + async def _run() -> list[str] | None: client = make_async_client() calls = _install_stub(client) try: method = getattr(client, method_name) - kwargs = _build_call_args(method) or {} + kwargs = _build_call_args(method) + if kwargs is None: + return None + try: - await method(**kwargs) + result = method(**kwargs) + # Async generators dispatch nothing until they are driven. + if inspect.isasyncgen(result): + async for _ in result: + break + else: + await result + except (TypeError, NotImplementedError) as exc: + pytest.fail(f"{method_name} is not callable as declared: {exc!r}") except Exception: + # As above: a payload-shape error still proves it dispatched. pass return calls finally: await client.close() - assert asyncio.run(_run()), f"{method_name} made no HTTP call on the async client" + calls = asyncio.run(_run()) + if calls is None: + pytest.skip(f"{method_name}: arguments could not be synthesized") + if method_name in _NO_TRANSPORT: + return + assert calls, ( + f"{method_name} made no HTTP call on the async client. Either it is " + f"not wired to the transport, or it belongs in _NO_TRANSPORT." + ) diff --git a/glpi_python_client/testing/tests/test_packaging.py b/glpi_python_client/testing/tests/test_packaging.py new file mode 100644 index 0000000..c6e7563 --- /dev/null +++ b/glpi_python_client/testing/tests/test_packaging.py @@ -0,0 +1,64 @@ +"""Unit tests must never be published. + +The wheel is what users install. Shipping the suite inside it bloats the +install, exposes fixtures as though they were API, and lets a test file +be imported from an installed package where its dev-only dependencies do +not exist. + +This asserts the exclusion patterns rather than building a wheel: a build +takes seconds and needs the ``build`` package, and the patterns are the +thing that actually regresses. The real wheel is asserted in CI. +""" + +from __future__ import annotations + +import pathlib +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised on 3.10 only + import tomli as tomllib + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + + +def _build_excludes() -> list[str]: + """Return the hatch build exclusion patterns.""" + + with (_REPO_ROOT / "pyproject.toml").open("rb") as handle: + config = tomllib.load(handle) + excludes = config["tool"]["hatch"]["build"]["exclude"] + assert isinstance(excludes, list) + return excludes + + +def test_test_directories_are_excluded_from_the_build() -> None: + """Any directory named ``tests`` is dropped from the wheel and sdist.""" + + assert "tests/" in _build_excludes() + + +def test_conftest_is_excluded_from_the_build() -> None: + """The generated per-tree conftest files are not published.""" + + assert "conftest.py" in _build_excludes() + + +def test_integration_tests_are_still_excluded() -> None: + """The pre-existing exclusion is not lost while adding the new ones.""" + + assert "integration_tests/" in _build_excludes() + + +def test_the_testing_helpers_are_still_published() -> None: + """``glpi_python_client.testing`` is a documented downstream helper. + + It is deliberately *not* excluded: docs/development.md advertises + ``make_client`` and ``make_async_client`` for downstream test suites. + A pattern that swept it up would break them silently. + """ + + excludes = _build_excludes() + assert "testing/" not in excludes + assert "glpi_python_client/testing/" not in excludes diff --git a/glpi_python_client/testing/tests/test_public_helpers.py b/glpi_python_client/testing/tests/test_public_helpers.py new file mode 100644 index 0000000..4c2aa5c --- /dev/null +++ b/glpi_python_client/testing/tests/test_public_helpers.py @@ -0,0 +1,103 @@ +"""Unit tests for the published :mod:`glpi_python_client.testing` helpers. + +These are the only part of ``testing/`` that downstream suites import, and +the library's own tests reach for the per-tree ``_testing`` twins instead -- +so nothing else here exercises them. Before this module, ``SearchResponse``, +``TicketResponse`` and the ``client_factory`` fixture shipped with no test +at all: their lines sat behind a coverage ``omit`` that matched the whole +``testing/`` package rather than just its suites. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from glpi_python_client import GlpiClient +from glpi_python_client.testing import ( + DEFAULT_CLIENT_CONFIG, + FakeResponse, + SearchResponse, + TicketResponse, + TokenResponse, +) + + +def test_search_response_carries_a_list_payload_and_defaults_to_200() -> None: + """``SearchResponse`` wraps a record list without restating the status.""" + + records = [{"id": 1}, {"id": 2}] + response = SearchResponse(records) + + assert response.status_code == 200 + assert response.json() == records + assert response.headers == {} + + +def test_search_response_forwards_status_and_headers() -> None: + """The overrides reach the ``FakeResponse`` base unchanged.""" + + response = SearchResponse( + [{"id": 1}], + status_code=206, + headers={"Content-Range": "0-0/1"}, + ) + + assert response.status_code == 206 + assert response.headers == {"Content-Range": "0-0/1"} + + +def test_ticket_response_carries_one_record_and_defaults_to_200() -> None: + """``TicketResponse`` wraps a single mapping, not a list.""" + + ticket = {"id": 7, "name": "printer jam"} + response = TicketResponse(ticket) + + assert response.status_code == 200 + assert response.json() == ticket + + +def test_ticket_response_forwards_status() -> None: + """A non-default status reaches the base class.""" + + assert TicketResponse({"id": 7}, status_code=201).status_code == 201 + + +def test_the_fake_responses_are_all_fake_response_subclasses() -> None: + """Downstream code may type against the base; the subclasses honour it.""" + + assert issubclass(SearchResponse, FakeResponse) + assert issubclass(TicketResponse, FakeResponse) + assert issubclass(TokenResponse, FakeResponse) + + +def test_client_factory_fixture_builds_a_usable_client( + client_factory: Callable[..., GlpiClient], +) -> None: + """The published pytest fixture yields a working client factory. + + ``conftest.py`` registers ``glpi_python_client.testing.fixtures`` as a + plugin, but no other test in this repository requests the fixture, so + this is the only thing proving the published entry point resolves and + returns something usable. + """ + + client = client_factory() + try: + assert isinstance(client, GlpiClient) + assert client.glpi_api_url == "https://glpi.example.test/api.php" + finally: + client.close() + + +def test_client_factory_fixture_accepts_overrides( + client_factory: Callable[..., GlpiClient], +) -> None: + """Overrides replace one default without restating the others.""" + + client = client_factory(glpi_api_url="https://other.example.test/api.php/") + try: + assert client.glpi_api_url == "https://other.example.test/api.php" + # Untouched defaults still come from the shared configuration. + assert client._auth._client_id == DEFAULT_CLIENT_CONFIG["client_id"] + finally: + client.close() diff --git a/glpi_python_client/tests/clients/test_raise_site_audit.py b/glpi_python_client/testing/tests/test_raise_site_audit.py similarity index 82% rename from glpi_python_client/tests/clients/test_raise_site_audit.py rename to glpi_python_client/testing/tests/test_raise_site_audit.py index a0be8a3..40a32de 100644 --- a/glpi_python_client/tests/clients/test_raise_site_audit.py +++ b/glpi_python_client/testing/tests/test_raise_site_audit.py @@ -79,19 +79,33 @@ def test_the_ast_walk_finds_a_known_raise_site() -> None: sites = _raise_sites() assert sites, "the raise-site walk found nothing -- it is broken" + # A root one level too shallow finds nothing and fails loudly above. A + # root one level too deep would still satisfy the endswith below, just + # with an extra leading segment -- so pin the root itself. + assert _PACKAGE_ROOT.name == "glpi_python_client", ( + f"_PACKAGE_ROOT resolved to {_PACKAGE_ROOT}, not the package root" + ) # clients/commons/_transport.py raises a deliberately-exempt # RuntimeError (decision D3) that this migration never touches, making # it a stable landmark to confirm the walk actually inspects source. - transport_sites = [ - site + transport_modules = { + site[0] for site in sites if site[0].endswith("clients/commons/_transport.py") and site[2] == "RuntimeError" - ] - assert transport_sites, ( + } + # Both trees, named exactly: _transport.py is generated, so the twin is + # as much a landmark as the source. Exact paths rather than a suffix + # test, so a root resolved one level too deep -- which would prefix + # every path with "glpi_python_client/" and still satisfy endswith -- + # fails here instead of passing quietly. + assert transport_modules == { + "_async/clients/commons/_transport.py", + "_sync/clients/commons/_transport.py", + }, ( "the raise-site walk did not find the known RuntimeError raise in " - "clients/commons/_transport.py -- it is not actually walking the " - "package" + "both copies of clients/commons/_transport.py -- it is not actually " + f"walking the package. Found: {sorted(transport_modules)}" ) diff --git a/glpi_python_client/tests/test_skill_references.py b/glpi_python_client/testing/tests/test_skill_references.py similarity index 99% rename from glpi_python_client/tests/test_skill_references.py rename to glpi_python_client/testing/tests/test_skill_references.py index f354c90..7305ba3 100644 --- a/glpi_python_client/tests/test_skill_references.py +++ b/glpi_python_client/testing/tests/test_skill_references.py @@ -30,7 +30,7 @@ from glpi_python_client import AsyncGlpiClient, GlpiClient -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] _SKILLS_DIR = _REPO_ROOT / "skills" # ``skills/`` ships in the sdist but not in the wheel, so an installed diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/testing/tests/test_unasync_codegen.py similarity index 61% rename from glpi_python_client/tests/test_unasync_codegen.py rename to glpi_python_client/testing/tests/test_unasync_codegen.py index ae04e05..1e6ccb2 100644 --- a/glpi_python_client/tests/test_unasync_codegen.py +++ b/glpi_python_client/testing/tests/test_unasync_codegen.py @@ -20,6 +20,7 @@ import ast import pathlib +import re import subprocess import sys @@ -30,7 +31,7 @@ reason="unasync is a dev-only dependency; codegen guards need it installed", ) -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] _BUILD_SCRIPT = _REPO_ROOT / "unasync_build.py" _ASYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_async" _SYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_sync" @@ -55,12 +56,28 @@ def _build_module() -> object: - """Import ``unasync_build`` from the repository root.""" + """Import ``unasync_build`` from the repository root. + + Inserting ``_REPO_ROOT`` is not enough to prove the import came from + there: pytest already puts the real repository root on ``sys.path`` + when it walks up past this package, and a previous test may have left + the module in ``sys.modules``. Either route resolves the import even + when ``_REPO_ROOT`` points somewhere else entirely, so every guard + below would keep passing against a root that no longer exists. Check + where the module actually came from instead of assuming. + """ sys.path.insert(0, str(_REPO_ROOT)) try: import unasync_build + origin = getattr(unasync_build, "__file__", None) + expected = _REPO_ROOT / "unasync_build.py" + assert origin is not None, "unasync_build has no __file__ to verify" + assert pathlib.Path(origin).resolve() == expected, ( + f"unasync_build was imported from {origin}, not {expected}: " + "_REPO_ROOT does not point at the repository root" + ) return unasync_build finally: sys.path.remove(str(_REPO_ROOT)) @@ -129,6 +146,41 @@ def test_no_identifier_collides_with_a_substitution_key() -> None: ) +#: Matches ``_async`` as its own NAME token -- i.e. a qualified reference +#: such as ``glpi_python_client._async.clients.api`` or a bare directory +#: mention such as ``_async/`` -- but not as a fragment embedded inside a +#: larger identifier such as ``test_glpi_client_async_context_manager``. +#: Python's tokenizer never splits an identifier at an underscore, so a +#: name like that is a single NAME token the codegen's own substitution +#: cannot touch and is not a reference to the async tree at all; only the +#: word-boundary-delimited occurrence is the thing this guard cares about. +_BARE_ASYNC_MENTION = re.compile(r"(? None:", False), + ("def test_async_transport_ensure_open_blocks_after_close() -> None:", False), + ], +) +def test_bare_async_mention_pattern_distinguishes_token_from_fragment( + line: str, expected: bool +) -> None: + """The pattern flags a standalone ``_async`` token but not an embedded one. + + Positive control for :data:`_BARE_ASYNC_MENTION`: without this, a future + edit could widen or narrow the pattern and + ``test_the_generated_tree_never_names_the_async_one`` would only notice + if the checked-in tree happened to contain a matching line that day. + """ + + assert bool(_BARE_ASYNC_MENTION.search(line)) is expected + + def test_the_generated_tree_never_names_the_async_one() -> None: """No module under ``_sync/`` mentions ``_async`` anywhere. @@ -149,6 +201,12 @@ def test_the_generated_tree_never_names_the_async_one() -> None: The hand-written twins are exempt. They are not generated, and each one names its counterpart on purpose: pointing at the other file is the only way to explain why the pair exists. + + The scan looks for ``_async`` as its own token, not as a substring -- + a colocated test such as ``test_glpi_client_async_context_manager`` + legitimately contains the letters ``_async`` without naming the async + tree, because it is a single identifier a reader (and the codegen) can + never split. See :data:`_BARE_ASYNC_MENTION`. """ build = _build_module() @@ -156,9 +214,10 @@ def test_the_generated_tree_never_names_the_async_one() -> None: offenders = [ f"{path.relative_to(_REPO_ROOT).as_posix()}:{lineno}: {line.strip()}" for path in sorted(_SYNC_DIR.rglob("*.py")) - if "__pycache__" not in path.parts and path.name not in hand_written + if "__pycache__" not in path.parts + and path.relative_to(_SYNC_DIR).as_posix() not in hand_written for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) - if "_async" in line + if _BARE_ASYNC_MENTION.search(line) ] assert offenders == [], ( "the generated sync tree still refers to the async one:\n" @@ -166,6 +225,70 @@ def test_the_generated_tree_never_names_the_async_one() -> None: ) +#: Lines under ``_sync/`` allowed to spell an async-only name. +#: +#: Each one is deliberately *contrastive*: it describes both surfaces at +#: once, so it stays true whichever tree a reader is in. Matched on the +#: stripped line so it survives the line moving, but not the sentence +#: being reworded -- a reword is exactly when the claim needs re-checking. +_CONTRASTIVE_LINES = frozenset( + { + "``AsyncGlpiClient`` is already a substitution key, so each copy returns", + "async surface -- an ``httpx.AsyncClient`` has no synchronous close, and a", + "session : httpx.Client | httpx.AsyncClient | None, optional", + ":class:`~glpi_python_client.AsyncGlpiClient` use.", + "``__aenter__``/``__aexit__``) stay on the concrete subclasses because", + "client -- :class:`glpi_python_client.AsyncGlpiClient` on the async", + "httpx.Client | httpx.AsyncClient", + "# surface: an ``httpx.AsyncClient`` has no synchronous close, and this", + } +) + + +def test_the_generated_tree_never_spells_an_async_only_name() -> None: + """No module under ``_sync/`` uses a substitution key un-rewritten. + + The sibling guard above covers ``_async``. This covers the other + fifteen keys -- ``AsyncGlpiClient``, ``httpx.AsyncClient``, + ``__aenter__``, ``aclose`` and the rest -- for the same reason and + against the same blind spot: substitution only fires when a string + literal's *entire* content is a key, so a name inside a multi-sentence + docstring passes through untouched and the generated tree documents + itself in terms that are false there. + + This is not hypothetical. Five such docstrings shipped before this + guard existed, two of them rendering into the published API reference: + ``__enter__`` was documented as returning ``AsyncGlpiClient``, and a + ``httpx.Client`` parameter was documented as ``httpx.AsyncClient``. + + Naming an async spelling is legitimate when the sentence names *both* + surfaces, which several do -- see :data:`_CONTRASTIVE_LINES`. Adding a + line there is a claim that it reads correctly from either tree. + """ + + build = _build_module() + hand_written: set[str] = build.HAND_WRITTEN # type: ignore[attr-defined] + keys = _substitution_keys() - {"_async"} + patterns = [ + (key, re.compile(rf"(? set[str]: """Return the name of every ``async def`` in ``_async/`` that yields.""" @@ -262,6 +385,18 @@ def test_gather_twins_agree_on_ordering() -> None: assert gather() == [] +def test_the_concurrency_test_twins_both_exist() -> None: + """Neither hand-written concurrency suite can go missing unnoticed. + + These two are exempt from generation, so the diff gate says nothing + about them. If one is deleted, its surface simply stops being tested + and everything stays green. + """ + + assert (_ASYNC_DIR / "tests" / "test_concurrency.py").is_file() + assert (_SYNC_DIR / "tests" / "test_concurrency.py").is_file() + + def test_the_checked_in_sync_tree_is_not_stale() -> None: """``_sync/`` matches what ``_async/`` currently generates. diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py index 05e9786..6fff29c 100644 --- a/glpi_python_client/testing/utils.py +++ b/glpi_python_client/testing/utils.py @@ -10,7 +10,12 @@ from glpi_python_client import AsyncGlpiClient, GlpiClient -_DEFAULT_CLIENT_CONFIG: dict[str, object] = { +#: Base constructor keywords shared by every in-memory test client. +#: +#: Exposed so the per-tree ``_testing`` twins can build a client for their +#: own surface without duplicating the configuration, and so downstream +#: suites can override one field without restating the rest. +DEFAULT_CLIENT_CONFIG: dict[str, object] = { "glpi_api_url": "https://glpi.example.test/api.php/", "client_id": "client-id", "client_secret": "client-secret", @@ -112,7 +117,7 @@ def make_client(**overrides: object) -> GlpiClient: configuration needed by most tests. """ - config = dict(_DEFAULT_CLIENT_CONFIG) + config = dict(DEFAULT_CLIENT_CONFIG) config.update(overrides) return GlpiClient(**config) # type: ignore[arg-type] @@ -125,6 +130,6 @@ def make_async_client(**overrides: object) -> AsyncGlpiClient: surface (and its bridge) without duplicating the base configuration. """ - config = dict(_DEFAULT_CLIENT_CONFIG) + config = dict(DEFAULT_CLIENT_CONFIG) config.update(overrides) return AsyncGlpiClient(**config) # type: ignore[arg-type] diff --git a/glpi_python_client/tests/__init__.py b/glpi_python_client/tests/__init__.py index 4461fb3..337c463 100644 --- a/glpi_python_client/tests/__init__.py +++ b/glpi_python_client/tests/__init__.py @@ -1 +1,4 @@ -"""Tests for the package-root modules.""" +"""Unit tests for the modules at the package root. + +Everything else lives beside the module it tests; see docs/development.md. +""" diff --git a/glpi_python_client/tests/api_knowledgebase/__init__.py b/glpi_python_client/tests/api_knowledgebase/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py b/glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py deleted file mode 100644 index 025efbd..0000000 --- a/glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Failure-status branch coverage for the Knowledge base mixins.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -import pytest - -from glpi_python_client import ( - GlpiClient, - PatchKBArticle, - PatchKBArticleComment, - PatchKBCategory, - PostKBArticle, - PostKBArticleComment, - PostKBCategory, -) -from glpi_python_client.testing.utils import FakeResponse, make_client - - -class _FailRecorder: - """Transport stub returning a fixed non-success status for every verb.""" - - def __init__(self, status: int) -> None: - self.status = status - - def install(self, client: GlpiClient) -> None: - def _resp(*args: Any, **kwargs: Any) -> FakeResponse: - return FakeResponse(status_code=self.status, payload={"err": "x"}) - - client._get_request = _resp # type: ignore[method-assign] - client._post_request = _resp # type: ignore[method-assign] - client._update_request = _resp # type: ignore[method-assign] - client._delete_request = _resp # type: ignore[method-assign] - - -@pytest.fixture -def client() -> GlpiClient: - return make_client() - - -_READ_CALLS: list[Callable[[GlpiClient], Any]] = [ - lambda c: c.get_kb_article(1), - lambda c: c.get_kb_category(1), - lambda c: c.list_kb_article_comments(1), - lambda c: c.get_kb_article_comment(1, 2), - lambda c: c.list_kb_article_revisions(1), - lambda c: c.get_kb_article_revision(1, 2), -] - -_WRITE_CALLS: list[Callable[[GlpiClient], Any]] = [ - lambda c: c.update_kb_article(1, PatchKBArticle(name="x")), - lambda c: c.update_kb_category(1, PatchKBCategory(name="x")), - lambda c: c.update_kb_article_comment(1, 2, PatchKBArticleComment(comment="x")), -] - -_DELETE_CALLS: list[Callable[[GlpiClient], Any]] = [ - lambda c: c.delete_kb_article(1, force=True), - lambda c: c.delete_kb_category(1, force=True), - lambda c: c.delete_kb_article_comment(1, 2, force=True), -] - -_CREATE_CALLS: list[Callable[[GlpiClient], Any]] = [ - lambda c: c.create_kb_article(PostKBArticle(name="x")), - lambda c: c.create_kb_category(PostKBCategory(name="x")), - lambda c: c.create_kb_article_comment(1, PostKBArticleComment(comment="x")), -] - - -@pytest.mark.parametrize("call", _READ_CALLS) -def test_read_helpers_raise_on_failure(client: GlpiClient, call: Callable) -> None: - _FailRecorder(404).install(client) - with pytest.raises(ValueError): - call(client) - - -@pytest.mark.parametrize("call", _CREATE_CALLS) -def test_create_helpers_raise_on_failure(client: GlpiClient, call: Callable) -> None: - _FailRecorder(500).install(client) - with pytest.raises(ValueError): - call(client) - - -@pytest.mark.parametrize("call", _WRITE_CALLS) -def test_update_helpers_raise_on_failure(client: GlpiClient, call: Callable) -> None: - _FailRecorder(500).install(client) - with pytest.raises(ValueError): - call(client) - - -@pytest.mark.parametrize("call", _DELETE_CALLS) -def test_delete_helpers_raise_on_failure(client: GlpiClient, call: Callable) -> None: - _FailRecorder(500).install(client) - with pytest.raises(ValueError): - call(client) diff --git a/glpi_python_client/tests/api_plugins/__init__.py b/glpi_python_client/tests/api_plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/glpi_python_client/tests/auth/__init__.py b/glpi_python_client/tests/auth/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/glpi_python_client/tests/clients/__init__.py b/glpi_python_client/tests/clients/__init__.py deleted file mode 100644 index 15aafa2..0000000 --- a/glpi_python_client/tests/clients/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the asynchronous GLPI client mixins.""" diff --git a/glpi_python_client/tests/clients/test_api_coverage.py b/glpi_python_client/tests/clients/test_api_coverage.py deleted file mode 100644 index 3819b8a..0000000 --- a/glpi_python_client/tests/clients/test_api_coverage.py +++ /dev/null @@ -1,888 +0,0 @@ -"""Coverage-focused tests for every public ``GlpiClient`` API mixin method. - -The tests reuse the recorder pattern from :mod:`test_smoke` to assert -endpoint URLs, HTTP verbs and serialised request bodies for the search, -get, update and delete operations that the existing smoke tests do not -already cover. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -import pytest - -from glpi_python_client import ( - GlpiClient, - GlpiValidationError, - PatchDocument, - PatchEntity, - PatchFollowup, - PatchLocation, - PatchSolution, - PatchTicket, - PatchTicketTask, - PatchTimelineDocument, - PatchUser, - PostDocument, - PostEntity, - PostTeamMember, -) -from glpi_python_client.testing.utils import FakeResponse, make_client - - -class _Recorder: - """Async transport recorder that drives FakeResponse responses.""" - - def __init__( - self, - *, - get_payload: Any = None, - get_status: int = 200, - get_content: bytes | None = None, - post_payload: Any = None, - post_status: int = 201, - patch_status: int = 204, - delete_status: int = 204, - ) -> None: - self.calls: list[dict[str, Any]] = [] - self._get_payload = get_payload if get_payload is not None else [] - self._get_status = get_status - self._get_content = get_content - self._post_payload = post_payload if post_payload is not None else {"id": 999} - self._post_status = post_status - self._patch_status = patch_status - self._delete_status = delete_status - - def install(self, client: GlpiClient) -> None: - """Replace the four transport helpers with capturing stubs.""" - - def _get( - endpoint: str, - params: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "GET", - "endpoint": endpoint, - "params": params, - "skip_entity": skip_entity, - } - ) - return FakeResponse( - status_code=self._get_status, - payload=self._get_payload, - content=self._get_content, - ) - - def _post( - endpoint: str, - json_body: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "POST", - "endpoint": endpoint, - "json": json_body, - "skip_entity": skip_entity, - } - ) - return FakeResponse( - status_code=self._post_status, payload=self._post_payload - ) - - def _patch( - endpoint: str, json_body: dict[str, Any] | None = None - ) -> FakeResponse: - self.calls.append( - {"method": "PATCH", "endpoint": endpoint, "json": json_body} - ) - return FakeResponse(status_code=self._patch_status, payload={}) - - def _delete( - endpoint: str, - json_body: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "DELETE", - "endpoint": endpoint, - "json": json_body, - "skip_entity": skip_entity, - } - ) - return FakeResponse(status_code=self._delete_status, payload={}) - - client._get_request = _get # type: ignore[method-assign] - client._post_request = _post # type: ignore[method-assign] - client._update_request = _patch # type: ignore[method-assign] - client._delete_request = _delete # type: ignore[method-assign] - - -@pytest.fixture -def client() -> GlpiClient: - """Return one in-memory client without any real HTTP plumbing.""" - - return make_client() - - -# --------------------------------------------------------------------------- -# Tickets -# --------------------------------------------------------------------------- - - -def test_search_tickets_forwards_sort_and_fields(client: GlpiClient) -> None: - """Sort and field selection both flow into the GET query parameters.""" - - rec = _Recorder(get_payload=[{"id": 1, "name": "n", "content": "c"}]) - rec.install(client) - tickets = client.search_tickets( - "status==1", limit=5, start=10, sort="date_mod desc", fields=("id", "name") - ) - - assert len(tickets) == 1 - assert rec.calls[0]["params"]["filter"] == "status==1" - assert rec.calls[0]["params"]["limit"] == 5 - assert rec.calls[0]["params"]["start"] == 10 - assert rec.calls[0]["params"]["sort"] == "date_mod desc" - assert rec.calls[0]["params"]["fields"] == "id,name" - - -def test_get_ticket_returns_validated_model(client: GlpiClient) -> None: - """Single ticket responses are validated through ``GetTicket``.""" - - rec = _Recorder(get_payload={"id": 7, "name": "demo", "content": "c
"}) - rec.install(client) - ticket = client.get_ticket(7) - assert ticket.id == 7 - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7" - - -def test_update_ticket_sends_patch(client: GlpiClient) -> None: - """Update sends a PATCH with the partial body.""" - - rec = _Recorder() - rec.install(client) - client.update_ticket(7, PatchTicket(content="x
")) - call = rec.calls[0] - assert call["method"] == "PATCH" - assert call["endpoint"] == "Assistance/Ticket/7" - assert call["json"] == {"content": "x
"} - - -def test_delete_ticket_omits_body_without_force(client: GlpiClient) -> None: - """``delete_ticket(force=None)`` omits the JSON body.""" - - rec = _Recorder() - rec.install(client) - client.delete_ticket(7) - call = rec.calls[0] - assert call["method"] == "DELETE" - assert call["endpoint"] == "Assistance/Ticket/7" - assert call["json"] is None - - -# --------------------------------------------------------------------------- -# Users -# --------------------------------------------------------------------------- - - -def test_search_users_forwards_skip_entity(client: GlpiClient) -> None: - """``search_users`` forwards the ``skip_entity`` flag.""" - - rec = _Recorder(get_payload=[{"id": 1, "username": "alice"}]) - rec.install(client) - users = client.search_users("username==alice", skip_entity=True) - assert len(users) == 1 - assert rec.calls[0]["skip_entity"] is True - assert rec.calls[0]["params"]["filter"] == "username==alice" - - -def test_get_user_targets_user_endpoint(client: GlpiClient) -> None: - """``get_user`` hits the per-id endpoint.""" - - rec = _Recorder(get_payload={"id": 5, "username": "alice"}) - rec.install(client) - user = client.get_user(5) - assert user.id == 5 - assert rec.calls[0]["endpoint"] == "Administration/User/5" - - -def test_update_user_sends_patch(client: GlpiClient) -> None: - """``update_user`` issues PATCH against the user endpoint.""" - - rec = _Recorder() - rec.install(client) - client.update_user(5, PatchUser(firstname="Alice")) - assert rec.calls[0]["method"] == "PATCH" - assert rec.calls[0]["endpoint"] == "Administration/User/5" - - -# --------------------------------------------------------------------------- -# Locations -# --------------------------------------------------------------------------- - - -def test_search_locations_passes_filter(client: GlpiClient) -> None: - """``search_locations`` forwards the RSQL filter through ``filter``.""" - - rec = _Recorder(get_payload=[{"id": 1, "name": "Paris"}]) - rec.install(client) - locations = client.search_locations("name==Paris") - assert locations[0].id == 1 - assert rec.calls[0]["endpoint"] == "Dropdowns/Location" - assert rec.calls[0]["params"]["filter"] == "name==Paris" - - -def test_get_location_endpoint(client: GlpiClient) -> None: - """``get_location`` hits the per-id endpoint.""" - - rec = _Recorder(get_payload={"id": 9, "name": "Paris"}) - rec.install(client) - loc = client.get_location(9) - assert loc.id == 9 - assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" - - -def test_update_location(client: GlpiClient) -> None: - """``update_location`` patches the per-id endpoint.""" - - rec = _Recorder() - rec.install(client) - client.update_location(9, PatchLocation(name="Paris HQ")) - assert rec.calls[0]["endpoint"] == "Dropdowns/Location/9" - - -def test_delete_location_with_force(client: GlpiClient) -> None: - """``delete_location(force=True)`` ships the force flag in the body.""" - - rec = _Recorder() - rec.install(client) - client.delete_location(9, force=True) - call = rec.calls[0] - assert call["method"] == "DELETE" - assert call["endpoint"] == "Dropdowns/Location/9" - assert call["json"] == {"force": True} - - -# --------------------------------------------------------------------------- -# Entities -# --------------------------------------------------------------------------- - - -def test_search_entities_skips_entity_header(client: GlpiClient) -> None: - """``search_entities`` skips the GLPI-Entity header.""" - - rec = _Recorder(get_payload=[{"id": 1, "name": "root"}]) - rec.install(client) - entities = client.search_entities("name==root", limit=None, start=0) - assert entities[0].id == 1 - assert rec.calls[0]["skip_entity"] is True - assert "limit" not in rec.calls[0]["params"] - - -def test_get_entity_skips_entity_header(client: GlpiClient) -> None: - """``get_entity`` also bypasses the entity header.""" - - rec = _Recorder(get_payload={"id": 2, "name": "root"}) - rec.install(client) - entity = client.get_entity(2) - assert entity.id == 2 - assert rec.calls[0]["endpoint"] == "Administration/Entity/2" - assert rec.calls[0]["skip_entity"] is True - - -def test_update_entity_patch(client: GlpiClient) -> None: - """``update_entity`` patches the per-id endpoint.""" - - rec = _Recorder() - rec.install(client) - client.update_entity(2, PatchEntity(name="renamed")) - assert rec.calls[0]["endpoint"] == "Administration/Entity/2" - - -def test_delete_entity_with_force(client: GlpiClient) -> None: - """``delete_entity(force=True)`` ships the force flag and skips entity.""" - - rec = _Recorder() - rec.install(client) - client.delete_entity(2, force=True) - call = rec.calls[0] - assert call["endpoint"] == "Administration/Entity/2" - assert call["json"] == {"force": True} - assert call["skip_entity"] is True - - -def test_create_entity_id_returned(client: GlpiClient) -> None: - """``create_entity`` returns the newly created identifier.""" - - rec = _Recorder(post_payload={"id": 42}) - rec.install(client) - entity_id = client.create_entity(PostEntity(name="root")) - assert entity_id == 42 - assert rec.calls[0]["endpoint"] == "Administration/Entity" - assert rec.calls[0]["skip_entity"] is True - - -# --------------------------------------------------------------------------- -# Documents (management) -# --------------------------------------------------------------------------- - - -def test_search_documents_filter_and_pagination(client: GlpiClient) -> None: - """``search_documents`` forwards the filter, limit, start, and skip_entity.""" - - rec = _Recorder(get_payload=[{"id": 1, "name": "doc"}]) - rec.install(client) - docs = client.search_documents("name==*manual*", limit=10, start=20) - assert len(docs) == 1 - call = rec.calls[0] - assert call["endpoint"] == "Management/Document" - assert call["skip_entity"] is True - assert call["params"]["limit"] == 10 - assert call["params"]["start"] == 20 - assert call["params"]["filter"] == "name==*manual*" - - -def test_get_document_endpoint(client: GlpiClient) -> None: - """``get_document`` hits the per-id endpoint.""" - - rec = _Recorder(get_payload={"id": 3, "name": "doc"}) - rec.install(client) - document = client.get_document(3) - assert document.id == 3 - assert rec.calls[0]["endpoint"] == "Management/Document/3" - - -def test_create_document_returns_id(client: GlpiClient) -> None: - """``create_document`` returns the new id and skips entity.""" - - rec = _Recorder(post_payload={"id": 77}) - rec.install(client) - document_id = client.create_document(PostDocument(name="manual")) - assert document_id == 77 - assert rec.calls[0]["endpoint"] == "Management/Document" - assert rec.calls[0]["skip_entity"] is True - - -def test_update_document_patches_endpoint(client: GlpiClient) -> None: - """``update_document`` issues PATCH on the per-id endpoint.""" - - rec = _Recorder() - rec.install(client) - client.update_document(3, PatchDocument(name="x")) - assert rec.calls[0]["endpoint"] == "Management/Document/3" - - -def test_delete_document_with_force(client: GlpiClient) -> None: - """``delete_document(force=True)`` adds the body and skips entity.""" - - rec = _Recorder() - rec.install(client) - client.delete_document(3, force=True) - call = rec.calls[0] - assert call["endpoint"] == "Management/Document/3" - assert call["json"] == {"force": True} - assert call["skip_entity"] is True - - -def test_download_document_returns_bytes(client: GlpiClient) -> None: - """``download_document_content`` returns the response bytes.""" - - rec = _Recorder( - get_status=200, get_payload={"ignored": True}, get_content=b"\x00ZZ" - ) - rec.install(client) - content = client.download_document_content(3) - assert content == b"\x00ZZ" - assert rec.calls[0]["endpoint"] == "Management/Document/3/Download" - - -def test_download_document_raises_on_failure(client: GlpiClient) -> None: - """A non-200 download status raises ``ValueError``.""" - - rec = _Recorder(get_status=404, get_payload={"err": "missing"}) - rec.install(client) - with pytest.raises(ValueError): - client.download_document_content(3) - - -def test_upload_document_requires_filename(client: GlpiClient) -> None: - """``upload_document`` rejects an empty filename before any HTTP call. - - ``GlpiValidationError`` inherits ``ValueError`` so existing callers that - catch the broader type keep working. - """ - - with pytest.raises(GlpiValidationError, match="filename") as excinfo: - client.upload_document(filename="", content=b"x") - assert isinstance(excinfo.value, ValueError) - - -def test_upload_document_dispatches_to_v1(client: GlpiClient) -> None: - """``upload_document`` forwards arguments to the configured v1 session.""" - - captured: dict[str, Any] = {} - - class _FakeV1: - def upload_document( - self, - filename: str, - content: bytes, - mime_type: str, - *, - document_name: str | None, - ticket_id: int | None, - entity_id: int | None, - ) -> dict[str, object]: - captured.update( - { - "filename": filename, - "content": content, - "mime_type": mime_type, - "document_name": document_name, - "ticket_id": ticket_id, - "entity_id": entity_id, - } - ) - return {"id": 1} - - client._v1 = _FakeV1() # type: ignore[assignment] - result = client.upload_document( - filename="a.txt", - content=b"abc", - mime_type="text/plain", - document_name="DocA", - ticket_id=5, - entity_id=2, - ) - - assert result == {"id": 1} - assert captured["filename"] == "a.txt" - assert captured["ticket_id"] == 5 - assert captured["entity_id"] == 2 - - -# --------------------------------------------------------------------------- -# Timeline mixins (followups, tasks, solutions, documents) -# --------------------------------------------------------------------------- - - -def test_list_ticket_followups_unwraps_envelope(client: GlpiClient) -> None: - """Live envelope ``{"type":..,"item":..}`` entries are unwrapped.""" - - rec = _Recorder( - get_payload=[ - {"type": "ITILFollowup", "item": {"id": 11, "content": "hi"}}, - {"id": 12, "content": "bye"}, - ] - ) - rec.install(client) - items = client.list_ticket_followups(7) - assert [i.id for i in items] == [11, 12] - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" - - -def test_get_ticket_followup_endpoint(client: GlpiClient) -> None: - """``get_ticket_followup`` hits the per-id endpoint.""" - - rec = _Recorder(get_payload={"id": 11, "content": "x"}) - rec.install(client) - followup = client.get_ticket_followup(7, 11) - assert followup.id == 11 - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" - - -def test_update_ticket_followup_patch(client: GlpiClient) -> None: - """``update_ticket_followup`` patches the per-id endpoint.""" - - rec = _Recorder() - rec.install(client) - client.update_ticket_followup(7, 11, PatchFollowup(content="up
")) - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup/11" - - -def test_delete_ticket_followup_force(client: GlpiClient) -> None: - """``delete_ticket_followup(force=True)`` adds the body.""" - - rec = _Recorder() - rec.install(client) - client.delete_ticket_followup(7, 11, force=True) - assert rec.calls[0]["json"] == {"force": True} - - -def test_list_get_update_delete_ticket_tasks(client: GlpiClient) -> None: - """All four task helpers target the task timeline endpoint.""" - - rec = _Recorder( - get_payload=[ - {"type": "TicketTask", "item": {"id": 1, "content": "x"}}, - ] - ) - rec.install(client) - tasks = client.list_ticket_tasks(7) - assert tasks[0].id == 1 - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Task" - - rec.calls.clear() - rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] - task = client.get_ticket_task(7, 1) - assert task.id == 1 - - client.update_ticket_task(7, 1, PatchTicketTask(content="up
")) - client.delete_ticket_task(7, 1, force=True) - - endpoints = [c["endpoint"] for c in rec.calls] - assert endpoints == [ - "Assistance/Ticket/7/Timeline/Task/1", - "Assistance/Ticket/7/Timeline/Task/1", - "Assistance/Ticket/7/Timeline/Task/1", - ] - - -def test_list_get_update_delete_ticket_solutions(client: GlpiClient) -> None: - """All four solution helpers target the solution timeline endpoint.""" - - rec = _Recorder( - get_payload=[ - {"type": "ITILSolution", "item": {"id": 1, "content": "x"}}, - ] - ) - rec.install(client) - sols = client.list_ticket_solutions(7) - assert sols[0].id == 1 - - rec._get_payload = {"id": 1, "content": "x"} # type: ignore[attr-defined] - sol = client.get_ticket_solution(7, 1) - assert sol.id == 1 - - client.update_ticket_solution(7, 1, PatchSolution(content="up
")) - client.delete_ticket_solution(7, 1, force=True) - - methods = [c["method"] for c in rec.calls] - assert methods == ["GET", "GET", "PATCH", "DELETE"] - endpoints = {c["endpoint"] for c in rec.calls if c["method"] != "GET"} | { - c["endpoint"] for c in rec.calls if c["method"] == "GET" - } - assert any("Solution" in e for e in endpoints) - - -def test_list_get_update_unlink_timeline_documents(client: GlpiClient) -> None: - """All four timeline document helpers target the document endpoint.""" - - rec = _Recorder( - get_payload=[ - {"type": "Document_Item", "item": {"id": 1, "filename": "report.txt"}}, - ] - ) - rec.install(client) - items = client.list_ticket_timeline_documents(7) - assert items[0].id == 1 - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Document" - - rec._get_payload = {"id": 1, "filename": "report.txt"} # type: ignore[attr-defined] - doc = client.get_ticket_timeline_document(7, 1) - assert doc.id == 1 - - client.update_ticket_timeline_document(7, 1, PatchTimelineDocument()) - client.unlink_ticket_timeline_document(7, 1, force=True) - - methods = [c["method"] for c in rec.calls] - assert methods == ["GET", "GET", "PATCH", "DELETE"] - - -# --------------------------------------------------------------------------- -# Team members -# --------------------------------------------------------------------------- - - -def test_list_ticket_team_members_endpoint(client: GlpiClient) -> None: - """``list_ticket_team_members`` hits the team-member endpoint.""" - - rec = _Recorder(get_payload=[{"id": 1, "type": "User", "role": "assigned"}]) - rec.install(client) - members = client.list_ticket_team_members(7) - assert members[0].id == 1 - assert rec.calls[0]["endpoint"] == "Assistance/Ticket/7/TeamMember" - - -def test_remove_ticket_team_member_uses_delete(client: GlpiClient) -> None: - """``remove_ticket_team_member`` issues DELETE with the member body.""" - - rec = _Recorder() - rec.install(client) - client.remove_ticket_team_member( - 7, PostTeamMember(type="User", id=42, role="assigned") - ) - - call = rec.calls[0] - assert call["method"] == "DELETE" - assert call["endpoint"] == "Assistance/Ticket/7/TeamMember" - assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} - - -# --------------------------------------------------------------------------- -# Generic error handling -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "call", - [ - lambda c: c.get_ticket(1), - lambda c: c.get_user(1), - lambda c: c.get_location(1), - lambda c: c.get_entity(1), - lambda c: c.get_document(1), - lambda c: c.get_ticket_followup(1, 2), - lambda c: c.get_ticket_task(1, 2), - lambda c: c.get_ticket_solution(1, 2), - lambda c: c.get_ticket_timeline_document(1, 2), - lambda c: c.list_ticket_team_members(1), - lambda c: c.list_ticket_followups(1), - lambda c: c.list_ticket_tasks(1), - lambda c: c.list_ticket_solutions(1), - lambda c: c.list_ticket_timeline_documents(1), - ], -) -def test_get_helpers_raise_on_failure_status( - client: GlpiClient, call: Callable[[GlpiClient], Any] -) -> None: - """Every read helper raises ``ValueError`` on a non-success status.""" - - rec = _Recorder(get_status=404, get_payload={"err": "missing"}) - rec.install(client) - with pytest.raises(ValueError): - call(client) - - -@pytest.mark.parametrize( - "call", - [ - lambda c: c.update_ticket(1, PatchTicket(content="x
")), - lambda c: c.update_user(1, PatchUser(firstname="x")), - lambda c: c.update_location(1, PatchLocation(name="x")), - lambda c: c.update_entity(1, PatchEntity(name="x")), - lambda c: c.update_document(1, PatchDocument(name="x")), - lambda c: c.update_ticket_followup(1, 2, PatchFollowup(content="x
")), - lambda c: c.update_ticket_task(1, 2, PatchTicketTask(content="x
")), - lambda c: c.update_ticket_solution(1, 2, PatchSolution(content="x
")), - lambda c: c.update_ticket_timeline_document(1, 2, PatchTimelineDocument()), - ], -) -def test_update_helpers_raise_on_failure_status( - client: GlpiClient, call: Callable[[GlpiClient], Any] -) -> None: - """Every update helper raises ``ValueError`` on a non-success status.""" - - rec = _Recorder(patch_status=500) - rec.install(client) - with pytest.raises(ValueError): - call(client) - - -@pytest.mark.parametrize( - "call", - [ - lambda c: c.delete_ticket(1, force=True), - lambda c: c.delete_user(1, force=True), - lambda c: c.delete_location(1, force=True), - lambda c: c.delete_entity(1, force=True), - lambda c: c.delete_document(1, force=True), - lambda c: c.delete_ticket_followup(1, 2, force=True), - lambda c: c.delete_ticket_task(1, 2, force=True), - lambda c: c.delete_ticket_solution(1, 2, force=True), - lambda c: c.unlink_ticket_timeline_document(1, 2, force=True), - lambda c: c.remove_ticket_team_member( - 1, PostTeamMember(type="User", id=2, role="assigned") - ), - ], -) -def test_delete_helpers_raise_on_failure_status( - client: GlpiClient, call: Callable[[GlpiClient], Any] -) -> None: - """Every delete helper raises ``ValueError`` on a non-success status.""" - - rec = _Recorder(delete_status=500) - rec.install(client) - with pytest.raises(ValueError): - call(client) - - -# --------------------------------------------------------------------------- -# iter_search_tickets -# --------------------------------------------------------------------------- - - -def test_iter_search_tickets_single_page(client: GlpiClient) -> None: - """A response shorter than batch_size yields one batch then stops.""" - - pages: list[list[Any]] = [[{"id": 1, "name": "t1", "content": "c"}]] - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return pages[0] - - client.search_tickets = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_tickets("status==1", batch_size=50)) - assert call_count == 1 - assert len(batches) == 1 - assert len(batches[0]) == 1 - - -def test_iter_search_tickets_multi_page_stops_on_short_batch( - client: GlpiClient, -) -> None: - """Iteration stops after the first batch shorter than batch_size.""" - - ticket_a = {"id": 1, "name": "a", "content": "c"} - ticket_b = {"id": 2, "name": "b", "content": "c"} - ticket_c = {"id": 3, "name": "c", "content": "c"} - responses = [ - [ticket_a, ticket_b], # full page → continue - [ticket_c], # short page → last - ] - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_tickets = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_tickets("", batch_size=2)) - assert call_count == 2 - assert len(batches) == 2 - assert len(batches[0]) == 2 - assert len(batches[1]) == 1 - - -# --------------------------------------------------------------------------- -# iter_search_users -# --------------------------------------------------------------------------- - - -def test_iter_search_users_single_page(client: GlpiClient) -> None: - """A response shorter than batch_size yields one batch then stops.""" - - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - skip_entity: bool = False, - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return [{"id": 1, "username": "alice"}] - - client.search_users = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_users("username==alice", batch_size=50)) - assert call_count == 1 - assert len(batches) == 1 - - -def test_iter_search_users_multi_page_stops_on_short_batch( - client: GlpiClient, -) -> None: - """Iteration stops after the first short user batch.""" - - responses = [ - [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}], - [{"id": 3, "username": "carol"}], - ] - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - skip_entity: bool = False, - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_users = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_users("", batch_size=2)) - assert call_count == 2 - assert sum(len(b) for b in batches) == 3 - - -# --------------------------------------------------------------------------- -# iter_search_entities -# --------------------------------------------------------------------------- - - -def test_iter_search_entities_single_page(client: GlpiClient) -> None: - """A response shorter than batch_size yields one batch then stops.""" - - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int | None = 50, - start: int = 0, - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return [{"id": 1, "name": "root"}] - - client.search_entities = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_entities("", batch_size=50)) - assert call_count == 1 - assert len(batches) == 1 - - -def test_iter_search_entities_multi_page_stops_on_short_batch( - client: GlpiClient, -) -> None: - """Iteration stops after the first short entity batch.""" - - responses = [ - [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], - [{"id": 3, "name": "c"}], - ] - call_count = 0 - - def fake_search( - rsql_filter: str = "", - *, - limit: int | None = 50, - start: int = 0, - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_entities = fake_search # type: ignore[method-assign] - batches = list(client.iter_search_entities("", batch_size=2)) - assert call_count == 2 - assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/tests/clients/test_smoke.py b/glpi_python_client/tests/clients/test_smoke.py deleted file mode 100644 index a4dd0c2..0000000 --- a/glpi_python_client/tests/clients/test_smoke.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Smoke tests covering the asynchronous API mixin call paths. - -The tests stub the asynchronous transport helpers on a real -:class:`GlpiClient` instance to exercise the per-endpoint mixins without -performing any network call. They focus on the interaction between mixins -and the transport layer and ensure the contract-aligned models are -serialised into the expected request bodies. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from glpi_python_client import ( - GlpiClient, - PostFollowup, - PostLocation, - PostSolution, - PostTeamMember, - PostTicket, - PostTicketTask, - PostTimelineDocument, - PostUser, -) -from glpi_python_client.testing.utils import FakeResponse, make_client - - -class _Recorder: - """Lightweight async stub recording transport calls for assertions.""" - - def __init__(self) -> None: - self.calls: list[dict[str, Any]] = [] - - def install(self, client: GlpiClient) -> None: - """Replace the transport methods on ``client`` with recording stubs.""" - - def _get( - endpoint: str, - params: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "GET", - "endpoint": endpoint, - "params": params, - "skip_entity": skip_entity, - } - ) - return FakeResponse(status_code=200, payload=self._next_get_payload()) - - def _post( - endpoint: str, - json_body: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "POST", - "endpoint": endpoint, - "json": json_body, - "skip_entity": skip_entity, - } - ) - return FakeResponse(status_code=201, payload={"id": 999}) - - def _patch( - endpoint: str, json_body: dict[str, Any] | None = None - ) -> FakeResponse: - self.calls.append( - {"method": "PATCH", "endpoint": endpoint, "json": json_body} - ) - return FakeResponse(status_code=204, payload={}) - - def _delete( - endpoint: str, - json_body: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "DELETE", - "endpoint": endpoint, - "json": json_body, - "skip_entity": skip_entity, - } - ) - return FakeResponse(status_code=204, payload={}) - - client._get_request = _get # type: ignore[method-assign] - client._post_request = _post # type: ignore[method-assign] - client._update_request = _patch # type: ignore[method-assign] - client._delete_request = _delete # type: ignore[method-assign] - - def _next_get_payload(self) -> object: - """Return a simple representative GET payload for list endpoints.""" - - return [{"id": 1, "name": "demo"}] - - -@pytest.fixture -def client() -> GlpiClient: - """Return one in-memory test client without any real HTTP plumbing.""" - - return make_client() - - -@pytest.fixture -def recorder(client: GlpiClient) -> _Recorder: - """Return one transport recorder already wired onto ``client``.""" - - rec = _Recorder() - rec.install(client) - return rec - - -def test_create_user_serialises_post_body( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_user`` serialises the ``PostUser`` model into the POST body.""" - - user_id = client.create_user(PostUser(username="alice")) - assert user_id == 999 - assert recorder.calls == [ - { - "method": "POST", - "endpoint": "Administration/User", - "json": {"username": "alice"}, - "skip_entity": False, - } - ] - - -def test_search_tickets_uses_filter_query_param( - client: GlpiClient, recorder: _Recorder -) -> None: - """``search_tickets`` forwards the RSQL filter via the ``filter`` parameter.""" - - tickets = client.search_tickets(rsql_filter="status==1", limit=20) - assert len(tickets) == 1 - assert recorder.calls[0]["method"] == "GET" - assert recorder.calls[0]["endpoint"] == "Assistance/Ticket" - assert recorder.calls[0]["params"]["filter"] == "status==1" - assert recorder.calls[0]["params"]["limit"] == 20 - - -def test_create_ticket_followup_targets_timeline_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_ticket_followup`` posts to the ticket timeline endpoint.""" - - client.create_ticket_followup(7, PostFollowup(content="hi
")) - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" - assert call["json"] == {"content": "hi
"} - - -def test_create_ticket_task_uses_task_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_ticket_task`` targets the ticket task timeline endpoint.""" - - client.create_ticket_task(8, PostTicketTask(content="task", duration=120)) - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket/8/Timeline/Task" - assert call["json"] == {"content": "task
", "duration": 120} - - -def test_create_ticket_solution_uses_solution_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_ticket_solution`` targets the ticket solution endpoint.""" - - client.create_ticket_solution(9, PostSolution(content="ok")) - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket/9/Timeline/Solution" - - -def test_link_ticket_timeline_document_targets_document_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``link_ticket_timeline_document`` targets the document timeline endpoint.""" - - client.link_ticket_timeline_document(10, PostTimelineDocument()) - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket/10/Timeline/Document" - assert call["json"] == {} - - -def test_add_ticket_team_member_targets_team_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``add_ticket_team_member`` posts to the ticket team-member endpoint.""" - - client.add_ticket_team_member( - 11, PostTeamMember(type="User", id=42, role="assigned") - ) - - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket/11/TeamMember" - assert call["json"] == {"type": "User", "id": 42, "role": "assigned"} - - -def test_create_entity_skips_entity_header( - client: GlpiClient, recorder: _Recorder -) -> None: - """Entity create requests bypass the GLPI-Entity header.""" - - from glpi_python_client import PostEntity - - client.create_entity(PostEntity(name="root")) - call = recorder.calls[0] - assert call["endpoint"] == "Administration/Entity" - assert call["skip_entity"] is True - - -def test_delete_user_supports_force_flag( - client: GlpiClient, recorder: _Recorder -) -> None: - """``delete_user`` forwards the ``force`` flag inside the JSON body.""" - - client.delete_user(5, force=True) - call = recorder.calls[0] - assert call["method"] == "DELETE" - assert call["endpoint"] == "Administration/User/5" - assert call["json"] == {"force": True} - - -def test_create_location_targets_dropdown_endpoint( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_location`` posts to the dropdown endpoint.""" - - client.create_location(PostLocation(name="Paris")) - call = recorder.calls[0] - assert call["endpoint"] == "Dropdowns/Location" - - -def test_upload_document_without_v1_raises(client: GlpiClient) -> None: - """``upload_document`` requires a v1 session to be configured.""" - - with pytest.raises(RuntimeError): - client.upload_document( - filename="a.bin", - content=b"x", - ) - - -def test_create_ticket_serialises_enums( - client: GlpiClient, recorder: _Recorder -) -> None: - """``create_ticket`` serialises enum values as their numeric form.""" - - client.create_ticket(PostTicket(name="t", content="c
")) - call = recorder.calls[0] - assert call["endpoint"] == "Assistance/Ticket" - assert call["json"]["name"] == "t" diff --git a/glpi_python_client/tests/commons/__init__.py b/glpi_python_client/tests/commons/__init__.py deleted file mode 100644 index df68862..0000000 --- a/glpi_python_client/tests/commons/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Tests for :mod:`glpi_python_client._sync.clients.commons` helpers. - -These tests exercise the small contract-aligned helpers shared by the API -mixins without dispatching real HTTP calls. -""" diff --git a/glpi_python_client/tests/commons/test_retry_semantics.py b/glpi_python_client/tests/commons/test_retry_semantics.py deleted file mode 100644 index dff5db6..0000000 --- a/glpi_python_client/tests/commons/test_retry_semantics.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Retry semantics for the v2 transport: 5xx is retried, 4xx is not. - -These tests are the regression net for the retry predicate. Getting the -predicate wrong disables retries silently — nothing raises, nothing fails, -requests simply stop being retried. See the 0.4.0 plan-1 notes. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from typing import Any - -import httpx -import pytest -from tenacity import wait_fixed - -from glpi_python_client import ( - GlpiClient, - GlpiError, - GlpiNotFoundError, - GlpiServerError, - GlpiTimeoutError, - GlpiTransportError, -) -from glpi_python_client._sync.clients.commons._http import ensure_response_status -from glpi_python_client.testing.utils import FakeResponse, make_client - -_RETRIED_METHODS = ( - "_get_request", - "_post_request", - "_update_request", - "_delete_request", -) - - -@pytest.fixture(autouse=True) -def _no_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None: - """Drop the 3s fixed wait so retry tests stay instant. - - The decorator's ``Retrying`` object is patched directly. Patching - ``tenacity.nap.time.sleep`` would work today but silently stops working - on the async path, so it is deliberately not used here. - """ - - for name in _RETRIED_METHODS: - monkeypatch.setattr(getattr(GlpiClient, name).retry, "wait", wait_fixed(0)) - - -@pytest.fixture -def client() -> Iterator[Any]: - """Return a client with auth stubbed so no token call is made.""" - - c = make_client() - c._auth.access_token = "test-token" - c._auth.ensure_token = lambda: None - yield c - c.close() - - -@pytest.mark.parametrize("method_name", _RETRIED_METHODS) -def test_5xx_is_retried_three_times_and_reraises_server_error( - client: Any, method_name: str -) -> None: - """A persistent 5xx costs 3 attempts and surfaces as ``GlpiServerError``. - - Parametrized across all four retried verbs (``_get_request``, - ``_post_request``, ``_update_request``, ``_delete_request``): they share - the same decorator, but before this test only ``_get_request``'s attempt - count was pinned. - """ - - attempts: list[int] = [] - - def _send(method: str, url: str, **kw: Any) -> FakeResponse: - attempts.append(1) - return FakeResponse( - status_code=500, payload={}, text="boom", reason="Server Error" - ) - - client._send_request = _send - with pytest.raises(GlpiServerError) as excinfo: - getattr(client, method_name)("Assistance/Ticket") - - assert len(attempts) == 3 - assert excinfo.value.status_code == 500 - assert excinfo.value.url == "https://glpi.example.test/api.php/Assistance/Ticket" - - -def test_persistent_5xx_does_not_surface_as_retry_error(client: Any) -> None: - """``reraise=True``: callers see the real error, never ``tenacity.RetryError``.""" - - import tenacity - - client._send_request = lambda method, url, **kw: FakeResponse( - status_code=503, payload={}, text="down", reason="Service Unavailable" - ) - with pytest.raises(GlpiServerError) as excinfo: - client._get_request("Assistance/Ticket") - assert not isinstance(excinfo.value, tenacity.RetryError) - - -@pytest.mark.parametrize("method_name", _RETRIED_METHODS) -def test_4xx_is_not_retried_by_the_transport(client: Any, method_name: str) -> None: - """A 4xx is logged and returned by ``finalize_request_response``, not retried. - - Parametrized across all four retried verbs so a predicate regression - that starts retrying 4xx on any single verb fails loudly. - """ - - attempts: list[int] = [] - - def _send(method: str, url: str, **kw: Any) -> FakeResponse: - attempts.append(1) - return FakeResponse(status_code=404, payload={}, text="nope") - - client._send_request = _send - response = getattr(client, method_name)("Assistance/Ticket/1") - - assert len(attempts) == 1 - assert response.status_code == 404 - - -def test_4xx_raises_a_typed_status_error_from_ensure_response_status() -> None: - """The 4xx raise stays in ``ensure_response_status`` and is typed.""" - - response = FakeResponse(status_code=404, payload={}, text="nope") - with pytest.raises(GlpiNotFoundError) as excinfo: - ensure_response_status( - response, - success_statuses=(200, 206), - failure_message="Failed to fetch ticket 1", - ) - - assert excinfo.value.status_code == 404 - assert isinstance(excinfo.value, ValueError) - assert str(excinfo.value) == "Failed to fetch ticket 1: 404 nope" - - -def test_tolerant_search_still_returns_empty_on_4xx(client: Any) -> None: - """Search endpoints that pass no ``failure_message`` still swallow a 4xx. - - Guards the 7 tolerant ``_resource_list`` call sites against the 4xx raise - being moved into ``finalize_request_response``. - """ - - client._send_request = lambda method, url, **kw: FakeResponse( - status_code=400, payload=[], text="[]" - ) - assert client.search_tickets() == [] - - -@pytest.mark.parametrize("method_name", _RETRIED_METHODS) -def test_network_errors_are_still_retried(client: Any, method_name: str) -> None: - """Real transport faults are translated and still retried three times. - - The fault is injected at ``session.request`` — *below* the translation - boundary — rather than by stubbing ``_send_request``. That matters: a stub - above the boundary would raise the HTTP library's own exception, which the - retry predicate no longer names, so the test would pass or fail for - reasons unrelated to the behaviour it is meant to pin. Injecting here - exercises the real path end to end: a genuine ``httpx`` fault, translated - into ``GlpiTransportError``, matched by the predicate, retried three - times, and surfaced to the caller as a library error. - - Parametrized across all four retried verbs so the network-fault attempt - count is pinned for each, not just ``_get_request``. - """ - - attempts: list[int] = [] - - def _request(method: str, url: str, **kw: Any) -> FakeResponse: - attempts.append(1) - raise httpx.ConnectError("network down") - - client._session.request = _request - with pytest.raises(GlpiTransportError): - getattr(client, method_name)("Assistance/Ticket") - - assert len(attempts) == 3 - - -@pytest.mark.parametrize("method_name", _RETRIED_METHODS) -def test_no_third_party_exception_reaches_the_caller( - client: Any, method_name: str -) -> None: - """A network fault never surfaces as the HTTP library's own exception. - - The public contract is that ``GlpiError`` is sufficient to catch the - library's failures. This pins the half of that promise which used to be - false: transport faults escaped as third-party exceptions, forcing callers - to import the HTTP library. If the translation is ever removed, the raw - exception reaches the caller and this fails. - """ - - def _request(method: str, url: str, **kw: Any) -> FakeResponse: - raise httpx.ConnectError("network down") - - client._session.request = _request - with pytest.raises(GlpiError) as excinfo: - getattr(client, method_name)("Assistance/Ticket") - - assert not isinstance(excinfo.value, httpx.HTTPError) - # The original fault stays reachable for debugging. - assert isinstance(excinfo.value.__cause__, httpx.ConnectError) - - -def test_timeouts_narrow_to_the_timeout_subclass(client: Any) -> None: - """A timeout surfaces as ``GlpiTimeoutError``, not just the base class. - - ``GlpiTimeoutError`` exists so callers can single out the "GLPI was too - slow" case from "GLPI was unreachable". That only works if the translation - actually inspects the fault type rather than flattening everything to the - base class. - """ - - def _request(method: str, url: str, **kw: Any) -> FakeResponse: - raise httpx.ConnectTimeout("too slow") - - client._session.request = _request - with pytest.raises(GlpiTimeoutError): - client._get_request("Assistance/Ticket") diff --git a/glpi_python_client/tests/commons/test_transport.py b/glpi_python_client/tests/commons/test_transport.py deleted file mode 100644 index a9e0474..0000000 --- a/glpi_python_client/tests/commons/test_transport.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Unit tests for the synchronous GLPI transport mixin. - -The tests exercise the core dispatch path — ``_ensure_token``, -``_send_request``, ``_execute_request``, and the four HTTP-verb helpers — -using a real :class:`GlpiClient` with its session and auth stubbed out so no -real network call is made. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from glpi_python_client.testing.utils import FakeResponse, make_client - - -@pytest.fixture -def client(): # type: ignore[no-untyped-def] - """Return a test client with auth and send_request pre-stubbed.""" - - c = make_client() - # Inject a ready access token and make ensure_token a no-op so the - # transport helpers can be called without network access. - c._auth.access_token = "test-token" - c._auth.ensure_token = lambda: None # type: ignore[method-assign] - # Stub _send_request at the seam level so _execute_request exercises the - # real header-building logic while returning a controlled response. - c._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign] - status_code=200, payload={"id": 1} - ) - yield c - c.close() - - -def test_ensure_token_calls_auth_manager(monkeypatch: pytest.MonkeyPatch) -> None: - """``_ensure_token`` invokes ``_auth.ensure_token`` on an open client.""" - - c = make_client() - called: list[bool] = [] - c._auth.ensure_token = lambda: called.append(True) # type: ignore[method-assign] - c._ensure_token() - assert called - c.close() - - -def test_send_request_dispatches_through_session_request( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``_send_request`` routes through ``session.request`` with an upper verb. - - The verb is passed as a *value* to ``request`` rather than being looked - up as a per-verb attribute: that is the one call shape ``requests`` and - ``httpx`` share, so the transport swap does not have to reason about - dynamic attribute lookup. - """ - - c = make_client() - fake = FakeResponse(status_code=200, payload={}) - seen: dict[str, object] = {} - - def _request(method: str, url: str, **kw: object) -> FakeResponse: - seen.update({"method": method, "url": url, "kw": kw}) - return fake - - monkeypatch.setattr(c._session, "request", _request) - result = c._send_request( - "get", "https://glpi.example.test/api.php/v2/test", timeout=30 - ) - assert result is fake - assert seen["method"] == "GET" - assert seen["url"] == "https://glpi.example.test/api.php/v2/test" - assert seen["kw"] == {"timeout": 30} - c.close() - - -def test_send_request_does_not_use_per_verb_attributes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stubbing only ``session.get`` must no longer intercept a GET. - - This pins the seam: if dispatch ever regresses to - ``getattr(session, verb)`` this test fails, because the per-verb - attribute would be used again. - """ - - c = make_client() - sentinel = FakeResponse(status_code=418, payload={}) - monkeypatch.setattr(c._session, "get", lambda url, **kw: sentinel) - captured: dict[str, object] = {} - - def _request(method: str, url: str, **kw: object) -> FakeResponse: - captured["used"] = True - return FakeResponse(status_code=200, payload={}) - - monkeypatch.setattr(c._session, "request", _request) - result = c._send_request("get", "https://glpi.example.test/api.php/v2/test") - assert captured.get("used") is True - assert result is not sentinel - c.close() - - -def test_execute_request_get_builds_params(client: Any) -> None: - """``_execute_request`` places query params on GET requests.""" - - captured: dict[str, Any] = {} - - def _capture(method: str, url: str, **kw: Any) -> FakeResponse: - captured.update({"method": method, "url": url, "kw": kw}) - return FakeResponse(status_code=200, payload={}) - - client._send_request = _capture # type: ignore[method-assign] - client._execute_request( - method="get", - endpoint="Assistance/Ticket", - success_statuses=(200,), - params={"range": "0-49"}, - ) - assert captured["method"] == "get" - assert "Assistance/Ticket" in captured["url"] - assert "params" in captured["kw"] - - -def test_execute_request_post_builds_json_body(client: Any) -> None: - """``_execute_request`` places the body in ``json`` for non-GET verbs.""" - - captured: dict[str, Any] = {} - - def _capture(method: str, url: str, **kw: Any) -> FakeResponse: - captured.update({"method": method, "kw": kw}) - return FakeResponse(status_code=201, payload={}) - - client._send_request = _capture # type: ignore[method-assign] - client._execute_request( - method="post", - endpoint="Assistance/Ticket", - success_statuses=(201,), - json_body={"name": "t"}, - include_content_type=True, - ) - assert captured["method"] == "post" - assert captured["kw"].get("json") == {"name": "t"} - - -def test_get_request_returns_response(client: Any) -> None: - """``_get_request`` dispatches via ``_execute_request`` and returns the response.""" - - resp = client._get_request("Assistance/Ticket") - assert resp.status_code == 200 - - -def test_post_request_returns_response(client: Any) -> None: - """``_post_request`` dispatches and returns the response.""" - - client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign] - status_code=201, payload={"id": 99} - ) - resp = client._post_request("Assistance/Ticket", json_body={"name": "t"}) - assert resp.status_code == 201 - - -def test_update_request_returns_response(client: Any) -> None: - """``_update_request`` dispatches and returns the response.""" - - client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign] - status_code=200, payload={} - ) - resp = client._update_request("Assistance/Ticket/1", json_body={"name": "u"}) - assert resp.status_code == 200 - - -def test_delete_request_returns_response(client: Any) -> None: - """``_delete_request`` dispatches and returns the response.""" - - client._send_request = lambda method, url, **kw: FakeResponse( # type: ignore[method-assign] - status_code=204, payload={} - ) - resp = client._delete_request("Assistance/Ticket/1") - assert resp.status_code == 204 diff --git a/glpi_python_client/tests/custom/__init__.py b/glpi_python_client/tests/custom/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index ee48995..b0f35f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,13 @@ exclude = [ ".coverage", "secrets/", "integration_tests/", + # Unit tests live beside the modules they test, so they sit inside the + # package directory. Both patterns are gitignore-style and match at any + # depth; "tests/" matches only a directory named exactly that, so + # glpi_python_client/testing/ -- a documented downstream helper -- is + # untouched. + "tests/", + "conftest.py", ] [project] @@ -130,17 +137,25 @@ branch = false source = ["glpi_python_client"] omit = [ "*/tests/*", - "glpi_python_client/testing/*", - # The async tree is the hand-written source; the sync tree is generated - # from it by a 1:1 token transform that only strips async/await. Every - # statement therefore exists in both, so measuring both would count the - # same logic twice and let real gaps hide behind a doubled denominator. - # Coverage is measured on the generated tree, which the suite exercises - # directly, and the correspondence between the two is enforced - # separately: unasync_build.py --check proves the trees match, mypy - # strict checks both, and tests/test_async_surface.py exercises the - # async surface at runtime -- including the paths that exist only there. - "glpi_python_client/_async/*", + # Only the suites, not the helpers. `glpi_python_client/testing/*` used + # to sit here, which silently excluded utils.py and fixtures.py too -- + # published API that downstream test suites import, measured by nothing. + # The first pattern already covers testing/tests/, so this is belt and + # braces against that line being reintroduced. + "glpi_python_client/testing/tests/*", + # The sync tree is generated from the async one by a 1:1 token transform + # that only strips async/await, so every statement exists in both. + # Measuring both would count the same logic twice and let real gaps hide + # behind a doubled denominator. + # + # Measure the async tree: it is the one a person edits, so it is the one + # a coverage report should be about. An uncovered line there is a line + # nobody wrote a test for; an uncovered line in _sync/ is an artefact of + # which surface a test happened to run on. Both trees are exercised -- + # the client suites are generated in step with the code -- and the + # correspondence is enforced separately: unasync_build.py --check proves + # the trees match and mypy strict checks both. + "glpi_python_client/_sync/*", ] [tool.coverage.report] diff --git a/unasync_build.py b/unasync_build.py index dfc122e..a2f7a7f 100644 --- a/unasync_build.py +++ b/unasync_build.py @@ -49,13 +49,25 @@ #: Files that are hand-written on *both* sides and never generated. #: -#: ``_concurrency.py`` is the only one. It is where the two trees genuinely -#: differ in kind rather than in syntax: a fan-out is ``asyncio.gather`` on -#: one side and plain sequential evaluation on the other, and the auth lock -#: is an ``asyncio.Lock`` on one side and a ``threading.Lock`` on the other. -#: Token substitution cannot express either, so both files are maintained by -#: hand and kept deliberately tiny. -HAND_WRITTEN = {"_concurrency.py"} +#: ``_concurrency.py`` is where the two trees genuinely differ in kind +#: rather than in syntax: a fan-out is ``asyncio.gather`` on one side and +#: plain sequential evaluation on the other, and the auth lock is an +#: ``asyncio.Lock`` on one side and a ``threading.Lock`` on the other. +#: Token substitution cannot express either, so both files are maintained +#: by hand and kept deliberately tiny. +#: +#: ``test_concurrency.py`` follows for the same reason. "Two tasks contend +#: the lock without deadlocking" has no sync twin worth generating -- the +#: sync side asserts that ``gather`` preserves order and evaluates in +#: sequence, which is a different claim about a different primitive. +#: +#: Entries are paths relative to each tree's root, not bare filenames. A +#: bare name would exempt *any* file so called at *any* depth, and the +#: omission would be invisible: the scratch tree and ``_sync/`` would agree +#: on its absence, so ``--check`` would stay green while a whole module +#: silently had no twin. Colocated tests make that collision plausible -- +#: generic names like ``test_concurrency.py`` now recur per package. +HAND_WRITTEN = {"_concurrency.py", "tests/test_concurrency.py"} #: Token substitutions beyond unasync's defaults. #: @@ -111,7 +123,10 @@ def _source_files() -> list[pathlib.Path]: return sorted( path for path in ASYNC_DIR.rglob("*.py") - if path.name not in HAND_WRITTEN and "__pycache__" not in path.parts + if ( + path.relative_to(ASYNC_DIR).as_posix() not in HAND_WRITTEN + and "__pycache__" not in path.parts + ) ) @@ -149,11 +164,10 @@ def _generate(into: pathlib.Path) -> None: # and demand their deletion. Generating in place leaves them untouched. if into == SYNC_DIR: return - for name in sorted(HAND_WRITTEN): - for existing in SYNC_DIR.rglob(name): - target = into / existing.relative_to(SYNC_DIR) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(existing, target) + for rel in sorted(HAND_WRITTEN): + target = into / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(SYNC_DIR / rel, target) def _relative_sync_files(root: pathlib.Path) -> dict[pathlib.Path, str]: