diff --git a/docs/api/_index.md b/docs/api/_index.md index f3599d7..abb7225 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -5,5 +5,5 @@ | [`chat`](./chat.md) | 1 | | [`database`](./database.md) | 18 | | [`echo`](./echo.md) | 1 | -| [`files`](./files.md) | 9 | +| [`files`](./files.md) | 10 | | [`membership`](./membership.md) | 18 | diff --git a/docs/api/files.md b/docs/api/files.md index f1a36d0..30dff14 100644 --- a/docs/api/files.md +++ b/docs/api/files.md @@ -12,6 +12,7 @@ Access with `norbix.api.files`. | `download_file_api` | `GET` | `/{version}/files/{filesIntegrationId}/download` | `project` | | `delete_file_api` | `DELETE` | `/{version}/files/{filesIntegrationId}` | `project` | | `delete_many_files_api` | `DELETE` | `/{version}/files/{filesIntegrationId}/bulk` | `project` | +| `test_files_integration` | `POST` | `/{version}/files/{filesIntegrationId}/test` | `project` | `filesIntegrationId` names the storage integration to work with. Get it from `norbix.hub.files.get_files_integrations()`. @@ -45,6 +46,34 @@ client.api.files.commit_upload( browser preview or a download link. `download_file_api` streams the bytes through Norbix instead. +## Testing an integration + +`test_files_integration` checks that a storage integration really works. It +runs a live probe: it uploads a small file, reads it back, lists the folder and +deletes the file again. Because the probe writes to the storage, the API key +needs the `files:create` permission. + +```python +result = client.api.files.test_files_integration(integration_id) +for step in result["items"]: + print(step["operation"], step["result"], step.get("errors")) +# UploadFile OK None +# GetFile OK None +# ... +``` + +Each item has `operation` (`UploadFile`, `GetFile`, `GetAllFiles`, +`DeleteFile`, in that order), `result` (`"OK"`, `"FAILED"`, or `"NOT_TESTED"` +once an earlier step failed) and `errors`. +When the gateway answers with an error status (for example for an unknown +integration id), the call raises a `NorbixError`, like the other methods; the +gateway's `responseStatus` is in the error's `details`. + +The dashboard has its own method for the same probe, +[`hub.files.test_files_integration`](../hub/files.md), which takes the id in the +body (`integrationId=...`) instead of the path. This one is for code that uses +an API key. + ## Reading a public link `get_public_file` reads a file somebody made public with diff --git a/src/norbix_python/api/files.py b/src/norbix_python/api/files.py index 60f34d9..833dfae 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -113,6 +113,32 @@ def request_upload_url(self, files_integration_id: str, *, timeout: float | None bearer_token=bearer_token, ) + def test_files_integration(self, files_integration_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/{filesIntegrationId}/test + + Hand-added by slice API-TEST (10b-files, #39) — the public API twin of + ``hub.files.test_files_integration`` (``POST /{version}/files/integrations/test``). + + Runs a live probe against the storage integration: it uploads a small + file, reads it back, lists the folder and deletes the file again. Needs + the ``files:create`` permission, because the probe writes to the storage. + + Gives back a dict with ``items`` — one entry per step, each with + ``operation`` (``UploadFile``, ``GetFile``, ``GetAllFiles``, ``DeleteFile``), + ``result`` (``"OK"``, ``"FAILED"``, or ``"NOT_TESTED"`` once an earlier + step failed) and ``errors``. + """ + return self._transport.send( + target="api", + path="/{version}/files/{filesIntegrationId}/test", + method="POST", + path_params={"filesIntegrationId": files_integration_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + # ------------------------------------------------------------------ # The public file link (10b-files slice PUB). Hand-added by slice SDK-2. # ------------------------------------------------------------------ @@ -266,6 +292,32 @@ async def request_upload_url(self, files_integration_id: str, *, timeout: float bearer_token=bearer_token, ) + async def test_files_integration(self, files_integration_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/{filesIntegrationId}/test + + Hand-added by slice API-TEST (10b-files, #39) — the public API twin of + ``hub.files.test_files_integration`` (``POST /{version}/files/integrations/test``). + + Runs a live probe against the storage integration: it uploads a small + file, reads it back, lists the folder and deletes the file again. Needs + the ``files:create`` permission, because the probe writes to the storage. + + Gives back a dict with ``items`` — one entry per step, each with + ``operation`` (``UploadFile``, ``GetFile``, ``GetAllFiles``, ``DeleteFile``), + ``result`` (``"OK"``, ``"FAILED"``, or ``"NOT_TESTED"`` once an earlier + step failed) and ``errors``. + """ + return await self._transport.send( + target="api", + path="/{version}/files/{filesIntegrationId}/test", + method="POST", + path_params={"filesIntegrationId": files_integration_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + # ------------------------------------------------------------------ # The public file link (10b-files slice PUB). Hand-added by slice SDK-2. # ------------------------------------------------------------------ diff --git a/tests/test_files_routes.py b/tests/test_files_routes.py index 4eb1377..a395d44 100644 --- a/tests/test_files_routes.py +++ b/tests/test_files_routes.py @@ -5,16 +5,25 @@ pointed at the wrong path would still pass them. These tests check the path itself, and that the values the caller passes reach the query string or the body. -One test per endpoint: 12 on the hub (the dashboard API) and 8 on the public API. +One test per endpoint: 12 on the hub (the dashboard API) and 9 on the public API, +plus a few more for ``api.files.test_files_integration`` (slice API-TEST, #39). Each test builds its own client and its own fake response, so the order the tests run in does not matter and no real storage provider is contacted. """ from __future__ import annotations +import asyncio import json +from typing import Any from urllib.parse import urlparse +import httpx +import pytest + +from norbix_python import AsyncNorbix, Norbix +from norbix_python.errors import ValidationError + from .helpers import make_client INTEGRATION_ID = "44444444-4444-4444-4444-444444444444" @@ -193,3 +202,133 @@ def test_delete_many_files() -> None: ) method, path, _, _ = _sent(transport) assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}/bulk") + + +# --- public API: testing an integration (slice API-TEST, #39) ---------------- +# +# POST /{version}/files/{filesIntegrationId}/test is the public API twin of the +# hub's POST /{version}/files/integrations/test. The two must not be mixed up: +# the id goes in the path here, in the body there. + +PROBE_ANSWER = { + "items": [ + {"operation": "UploadFile", "result": "OK", "errors": []}, + {"operation": "GetFile", "result": "FAILED", "errors": ["Access denied"]}, + {"operation": "GetAllFiles", "result": "NOT_TESTED", "errors": []}, + {"operation": "DeleteFile", "result": "NOT_TESTED", "errors": []}, + ], + "responseStatus": {"errorCode": "", "message": ""}, +} + + +class _Answers(httpx.BaseTransport): + """Records the request and answers with a fixed status and JSON body.""" + + def __init__(self, status: int, payload: dict[str, Any]) -> None: + self.status = status + self.payload = payload + self.last_request: dict[str, Any] | None = None + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.last_request = { + "method": request.method, + "url": str(request.url), + "headers": dict(request.headers), + "body": request.content.decode("utf-8") if request.content else "", + } + return httpx.Response(self.status, json=self.payload) + + +class _AsyncAnswers(httpx.AsyncBaseTransport): + def __init__(self, status: int, payload: dict[str, Any]) -> None: + self.status = status + self.payload = payload + self.last_request: dict[str, Any] | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.last_request = { + "method": request.method, + "url": str(request.url), + "headers": dict(request.headers), + "body": request.content.decode("utf-8") if request.content else "", + } + return httpx.Response(self.status, json=self.payload) + + +def test_api_test_files_integration_route() -> None: + client, transport = make_client() + client.api.files.test_files_integration(INTEGRATION_ID) + method, path, _, body = _sent(transport) + assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/test") + # The id travels in the path only, not in the body as on the hub route. + assert "integrationId" not in body + + +def test_api_test_files_integration_sends_project_scope_headers() -> None: + client, transport = make_client() + client.api.files.test_files_integration(INTEGRATION_ID) + headers = transport.last_request["headers"] + assert headers["authorization"] == "Bearer test-token" + assert headers["x-cm-projectid"] == "test-project" + + +def test_api_test_files_integration_gives_back_the_parsed_items() -> None: + transport = _Answers(200, PROBE_ANSWER) + client = Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=transport), + ) + + result = client.api.files.test_files_integration(INTEGRATION_ID) + + assert [item["operation"] for item in result["items"]] == [ + "UploadFile", + "GetFile", + "GetAllFiles", + "DeleteFile", + ] + assert result["items"][0]["result"] == "OK" + assert result["items"][1] == {"operation": "GetFile", "result": "FAILED", "errors": ["Access denied"]} + + +def test_api_test_files_integration_error_status_raises() -> None: + answer = { + "responseStatus": { + "errorCode": "IntegrationNotFound", + "message": "Files integration was not found", + } + } + transport = _Answers(400, answer) + client = Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=transport), + ) + + with pytest.raises(ValidationError) as caught: + client.api.files.test_files_integration(INTEGRATION_ID) + + assert caught.value.status == 400 + assert caught.value.details["responseStatus"]["errorCode"] == "IntegrationNotFound" + + +def test_api_test_files_integration_async() -> None: + transport = _AsyncAnswers(200, PROBE_ANSWER) + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=transport), + ) + + async def run() -> Any: + return await client.api.files.test_files_integration(INTEGRATION_ID) + + result = asyncio.run(run()) + + request = transport.last_request + assert request is not None + assert request["method"] == "POST" + assert urlparse(request["url"]).path == f"/v2/files/{INTEGRATION_ID}/test" + assert request["headers"]["x-cm-projectid"] == "test-project" + assert result["items"][2]["operation"] == "GetAllFiles"