From 645a4cf8177fbe1242aef156116890f8b325b95c Mon Sep 17 00:00:00 2001 From: CodeMash Date: Sat, 19 Sep 2026 11:54:01 +0300 Subject: [PATCH 1/2] [10b-API-TEST] feat(files): test_files_integration on the API client Adds api.files.test_files_integration (sync and async) for the public API endpoint POST /{version}/files/{filesIntegrationId}/test, which runs a live upload/read/list/delete probe against a files integration (#39). The hub method hub.files.test_files_integration (POST /{version}/files/integrations/test) is unchanged. Tests (fake transport): route + verb, project-scope headers, parsed items, error status raises ValidationError with responseStatus in details, async. Docs: docs/api/files.md section + table row, docs/api/_index.md count 9 -> 10. Co-Authored-By: Claude Opus 5 --- docs/api/_index.md | 2 +- docs/api/files.md | 27 +++++++ src/norbix_python/api/files.py | 48 ++++++++++++ tests/test_files_routes.py | 136 ++++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 2 deletions(-) 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..e7adc99 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,32 @@ 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")) +# Upload OK None +# Read OK None +# ... +``` + +Each item has `operation`, `result` (`"OK"` or `"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..dcf50f9 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -113,6 +113,30 @@ 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``, ``result`` (``"OK"`` or ``"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 +290,30 @@ 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``, ``result`` (``"OK"`` or ``"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..e6b22c6 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,128 @@ 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": "Upload", "result": "OK", "errors": []}, + {"operation": "Read", "result": "OK", "errors": []}, + {"operation": "List", "result": "OK", "errors": []}, + {"operation": "Delete", "result": "Failed", "errors": ["Access denied"]}, + ], + "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"]] == ["Upload", "Read", "List", "Delete"] + assert result["items"][0]["result"] == "OK" + assert result["items"][3] == {"operation": "Delete", "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"] == "List" From 3e53ecbde3f718a9ea78eb906c26753dfd37067f Mon Sep 17 00:00:00 2001 From: CodeMash Date: Sat, 19 Sep 2026 12:12:44 +0300 Subject: [PATCH 2/2] [10b-API-TEST] fix(files): real probe step names and results in docs and tests Co-Authored-By: Claude Opus 5 --- docs/api/files.md | 8 +++++--- src/norbix_python/api/files.py | 8 ++++++-- tests/test_files_routes.py | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/api/files.md b/docs/api/files.md index e7adc99..30dff14 100644 --- a/docs/api/files.md +++ b/docs/api/files.md @@ -57,12 +57,14 @@ needs the `files:create` permission. result = client.api.files.test_files_integration(integration_id) for step in result["items"]: print(step["operation"], step["result"], step.get("errors")) -# Upload OK None -# Read OK None +# UploadFile OK None +# GetFile OK None # ... ``` -Each item has `operation`, `result` (`"OK"` or `"Failed"`) and `errors`. +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`. diff --git a/src/norbix_python/api/files.py b/src/norbix_python/api/files.py index dcf50f9..833dfae 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -124,7 +124,9 @@ def test_files_integration(self, files_integration_id: str, *, timeout: float | the ``files:create`` permission, because the probe writes to the storage. Gives back a dict with ``items`` — one entry per step, each with - ``operation``, ``result`` (``"OK"`` or ``"Failed"``) and ``errors``. + ``operation`` (``UploadFile``, ``GetFile``, ``GetAllFiles``, ``DeleteFile``), + ``result`` (``"OK"``, ``"FAILED"``, or ``"NOT_TESTED"`` once an earlier + step failed) and ``errors``. """ return self._transport.send( target="api", @@ -301,7 +303,9 @@ async def test_files_integration(self, files_integration_id: str, *, timeout: fl the ``files:create`` permission, because the probe writes to the storage. Gives back a dict with ``items`` — one entry per step, each with - ``operation``, ``result`` (``"OK"`` or ``"Failed"``) and ``errors``. + ``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", diff --git a/tests/test_files_routes.py b/tests/test_files_routes.py index e6b22c6..a395d44 100644 --- a/tests/test_files_routes.py +++ b/tests/test_files_routes.py @@ -212,10 +212,10 @@ def test_delete_many_files() -> None: PROBE_ANSWER = { "items": [ - {"operation": "Upload", "result": "OK", "errors": []}, - {"operation": "Read", "result": "OK", "errors": []}, - {"operation": "List", "result": "OK", "errors": []}, - {"operation": "Delete", "result": "Failed", "errors": ["Access denied"]}, + {"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": ""}, } @@ -282,9 +282,14 @@ def test_api_test_files_integration_gives_back_the_parsed_items() -> None: result = client.api.files.test_files_integration(INTEGRATION_ID) - assert [item["operation"] for item in result["items"]] == ["Upload", "Read", "List", "Delete"] + assert [item["operation"] for item in result["items"]] == [ + "UploadFile", + "GetFile", + "GetAllFiles", + "DeleteFile", + ] assert result["items"][0]["result"] == "OK" - assert result["items"][3] == {"operation": "Delete", "result": "Failed", "errors": ["Access denied"]} + assert result["items"][1] == {"operation": "GetFile", "result": "FAILED", "errors": ["Access denied"]} def test_api_test_files_integration_error_status_raises() -> None: @@ -326,4 +331,4 @@ async def run() -> Any: 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"] == "List" + assert result["items"][2]["operation"] == "GetAllFiles"