scaffold API client modules for homelab services
All checks were successful
ci/woodpecker/push/infra-ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Base client extracted from bcda/client.py retry/auth pattern with
MockTransport injection for testing. Four service clients: Gitea,
Woodpecker, RustFS (S3 + admin), and Zotero — each with auth
headers and domain method stubs. 36 tests passing.
This commit is contained in:
kert
2026-02-28 20:01:29 -05:00
parent 89f8dec187
commit 9e111e889f
18 changed files with 1087 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Programmatic API clients for homelab services."""

View File

@@ -0,0 +1,9 @@
"""API client implementations."""
from .base import ApiError as ApiError
from .base import Client as Client
from .gitea import GiteaClient as GiteaClient
from .rustfs import RustFSAdmin as RustFSAdmin
from .rustfs import RustFSClient as RustFSClient
from .woodpecker import WoodpeckerClient as WoodpeckerClient
from .zotero import ZoteroClient as ZoteroClient

218
src/api/clients/base.py Normal file
View File

@@ -0,0 +1,218 @@
"""Shared HTTP client with retry, auth, and error handling."""
from __future__ import annotations
import logging
import time
import httpx
log = logging.getLogger(__name__)
MAX_RETRIES = 3
RETRY_INITIAL_INTERVAL = 1.0 # seconds
class ApiError(Exception):
"""Base exception for API client errors."""
class Client:
"""Base HTTP client with retry and auth hooks.
Parameters
----------
base_url : str
API base URL (e.g. ``http://gitea:3000/api/v1``).
max_retries : int
Max retry attempts for transient errors.
retry_interval : float
Initial back-off interval in seconds (doubles each retry).
timeout : float
HTTP request timeout in seconds.
_transport : httpx.BaseTransport | None
Injected transport for testing (``httpx.MockTransport``).
"""
def __init__(
self,
base_url: str,
*,
max_retries: int = MAX_RETRIES,
retry_interval: float = RETRY_INITIAL_INTERVAL,
timeout: float = 30.0,
_transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.max_retries = max_retries
self.retry_interval = retry_interval
self._http = httpx.Client(
timeout=timeout,
**({"transport": _transport} if _transport else {}),
)
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http.close()
def __enter__(self) -> Client:
return self
def __exit__(self, *exc: object) -> None:
self.close()
# ── Auth hooks (override in subclasses) ──────────────────
def _default_headers(self) -> dict[str, str]:
"""Return headers included on every request.
Override to add static auth tokens.
"""
return {}
def authenticate(self) -> None:
"""Perform dynamic authentication (e.g. token refresh).
No-op by default. Override for OAuth / dynamic token flows.
"""
# ── Low-level request with retry ─────────────────────────
def _request(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, str] | None = None,
json: object = None,
content: bytes | None = None,
auth_required: bool = True,
retry: bool = True,
) -> httpx.Response:
"""Execute an HTTP request with retry and token refresh.
Parameters
----------
method : str
HTTP method.
path : str
Path relative to ``base_url``.
headers : dict | None
Extra headers merged with ``_default_headers()``.
params : dict | None
Query parameters.
json : object
JSON-serializable body.
content : bytes | None
Raw body bytes.
auth_required : bool
Whether to include auth headers.
retry : bool
Whether to apply retry logic.
"""
url = f"{self.base_url}{path}"
merged: dict[str, str] = {}
if auth_required:
merged.update(self._default_headers())
if headers:
merged.update(headers)
attempts = self.max_retries if retry else 1
backoff = self.retry_interval
last_exc: BaseException | None = None
refreshed = False
for attempt in range(1, attempts + 1):
try:
resp = self._http.request(
method,
url,
headers=merged,
params=params,
json=json,
content=content,
)
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
last_exc = exc
if attempt < attempts:
log.warning(
"%s %s attempt %d/%d: %s — retrying in %.1fs",
method,
url,
attempt,
attempts,
exc,
backoff,
)
time.sleep(backoff)
backoff *= 2
continue
raise ApiError(
f"{method} {url} failed after {attempts} attempts"
) from exc
if resp.status_code == 401 and auth_required and not refreshed:
log.info("Got 401, re-authenticating")
self.authenticate()
merged.update(self._default_headers())
refreshed = True
continue
if resp.status_code == 429:
delay = int(resp.headers.get("Retry-After", "60"))
log.warning(
"Rate limited (429), sleeping %ds (attempt %d/%d)",
delay,
attempt,
attempts,
)
time.sleep(delay)
continue
if resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"Server error {resp.status_code}",
request=resp.request,
response=resp,
)
if attempt < attempts:
log.warning(
"%s %s returned %d (attempt %d/%d) — retrying",
method,
url,
resp.status_code,
attempt,
attempts,
)
time.sleep(backoff)
backoff *= 2
continue
resp.raise_for_status()
resp.raise_for_status()
return resp
if last_exc:
raise ApiError(str(last_exc)) from last_exc
raise ApiError("Request failed with no response")
# ── Convenience verbs ────────────────────────────────────
def get(
self, path: str, *, params: dict[str, str] | None = None, **kw
) -> httpx.Response:
return self._request("GET", path, params=params, **kw)
def post(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("POST", path, json=json, **kw)
def put(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("PUT", path, json=json, **kw)
def delete(self, path: str, **kw) -> httpx.Response:
return self._request("DELETE", path, **kw)
def patch(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("PATCH", path, json=json, **kw)

View File

@@ -0,0 +1,3 @@
"""Gitea API client."""
from .client import GiteaClient as GiteaClient

View File

@@ -0,0 +1,62 @@
"""Gitea API client."""
from __future__ import annotations
from api.clients.base import Client
class GiteaClient(Client):
"""Client for the Gitea REST API v1.
Parameters
----------
token : str
Personal access token.
base_url : str
Gitea API base URL.
"""
def __init__(
self,
token: str,
*,
base_url: str = "http://gitea:3000/api/v1",
**kw,
) -> None:
super().__init__(base_url, **kw)
self._token = token
def _default_headers(self) -> dict[str, str]:
return {"Authorization": f"token {self._token}"}
# ── Repos ────────────────────────────────────────────────
def list_repos(self, **params) -> list[dict]:
return self.get("/repos/search", params=params).json()
def get_repo(self, owner: str, repo: str) -> dict:
return self.get(f"/repos/{owner}/{repo}").json()
def create_repo(self, body: dict) -> dict:
return self.post("/user/repos", json=body).json()
# ── Orgs ─────────────────────────────────────────────────
def list_orgs(self) -> list[dict]:
return self.get("/user/orgs").json()
def get_org(self, org: str) -> dict:
return self.get(f"/orgs/{org}").json()
# ── Packages ─────────────────────────────────────────────
def list_packages(self, owner: str, **params) -> list[dict]:
return self.get(f"/packages/{owner}", params=params).json()
# ── Webhooks ─────────────────────────────────────────────
def list_webhooks(self, owner: str, repo: str) -> list[dict]:
return self.get(f"/repos/{owner}/{repo}/hooks").json()
def create_webhook(self, owner: str, repo: str, body: dict) -> dict:
return self.post(f"/repos/{owner}/{repo}/hooks", json=body).json()

View File

@@ -0,0 +1,4 @@
"""RustFS S3-compatible and admin API clients."""
from .client import RustFSAdmin as RustFSAdmin
from .client import RustFSClient as RustFSClient

View File

@@ -0,0 +1,150 @@
"""RustFS (S3-compatible) and MinIO admin API clients."""
from __future__ import annotations
import httpx
from api.clients.base import Client
class RustFSClient(Client):
"""S3-protocol client for RustFS object storage.
Parameters
----------
access_key : str
S3 access key ID.
secret_key : str
S3 secret access key.
base_url : str
RustFS S3 endpoint.
Notes
-----
AWS Signature V4 signing is stubbed — requests are sent unsigned.
Implement ``_sign_request()`` when real S3 auth is needed.
"""
def __init__(
self,
access_key: str,
secret_key: str,
*,
base_url: str = "http://rustfs:9000",
**kw,
) -> None:
super().__init__(base_url, **kw)
self._access_key = access_key
self._secret_key = secret_key
# ── Buckets ──────────────────────────────────────────────
def list_buckets(self) -> httpx.Response:
return self.get("/", auth_required=False)
def create_bucket(self, bucket: str) -> httpx.Response:
return self.put(f"/{bucket}", auth_required=False)
def delete_bucket(self, bucket: str) -> httpx.Response:
return self.delete(f"/{bucket}", auth_required=False)
def bucket_exists(self, bucket: str) -> bool:
resp = self._request("HEAD", f"/{bucket}", auth_required=False, retry=False)
return resp.status_code == 200
# ── Objects ──────────────────────────────────────────────
def list_objects(self, bucket: str, *, prefix: str = "") -> httpx.Response:
params = {"prefix": prefix} if prefix else None
return self.get(f"/{bucket}", params=params, auth_required=False)
def get_object(self, bucket: str, key: str) -> httpx.Response:
return self.get(f"/{bucket}/{key}", auth_required=False)
def put_object(self, bucket: str, key: str, data: bytes) -> httpx.Response:
return self._request(
"PUT",
f"/{bucket}/{key}",
content=data,
auth_required=False,
)
def delete_object(self, bucket: str, key: str) -> httpx.Response:
return self.delete(f"/{bucket}/{key}", auth_required=False)
def head_object(self, bucket: str, key: str) -> httpx.Response:
return self._request(
"HEAD",
f"/{bucket}/{key}",
auth_required=False,
retry=False,
)
class RustFSAdmin(Client):
"""MinIO-compatible admin API client for RustFS.
Parameters
----------
access_key : str
Admin access key.
secret_key : str
Admin secret key.
base_url : str
RustFS admin endpoint.
Notes
-----
Admin credential signing is stubbed.
"""
def __init__(
self,
access_key: str,
secret_key: str,
*,
base_url: str = "http://rustfs:9000/minio/v2",
**kw,
) -> None:
super().__init__(base_url, **kw)
self._access_key = access_key
self._secret_key = secret_key
# ── Users ────────────────────────────────────────────────
def list_users(self) -> dict:
return self.get("/iam/users", auth_required=False).json()
def add_user(self, access_key: str, secret_key: str) -> dict:
return self.post(
"/iam/users",
json={"accessKey": access_key, "secretKey": secret_key},
auth_required=False,
).json()
def remove_user(self, access_key: str) -> httpx.Response:
return self.delete(f"/iam/users/{access_key}", auth_required=False)
# ── Policies ─────────────────────────────────────────────
def list_policies(self) -> dict:
return self.get("/iam/policies", auth_required=False).json()
def add_policy(self, name: str, policy: dict) -> dict:
return self.post(
f"/iam/policies/{name}",
json=policy,
auth_required=False,
).json()
def set_user_policy(self, access_key: str, policy: str) -> httpx.Response:
return self.put(
f"/iam/users/{access_key}/policies",
json={"policy": policy},
auth_required=False,
)
# ── Server ───────────────────────────────────────────────
def server_info(self) -> dict:
return self.get("/cluster/info", auth_required=False).json()

View File

@@ -0,0 +1,3 @@
"""Woodpecker CI API client."""
from .client import WoodpeckerClient as WoodpeckerClient

View File

@@ -0,0 +1,62 @@
"""Woodpecker CI API client."""
from __future__ import annotations
from api.clients.base import Client
class WoodpeckerClient(Client):
"""Client for the Woodpecker CI REST API.
Parameters
----------
token : str
Personal API token.
base_url : str
Woodpecker API base URL.
"""
def __init__(
self,
token: str,
*,
base_url: str = "http://woodpecker-server:8000/api",
**kw,
) -> None:
super().__init__(base_url, **kw)
self._token = token
def _default_headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._token}"}
# ── Repos ────────────────────────────────────────────────
def list_repos(self) -> list[dict]:
return self.get("/repos").json()
def get_repo(self, repo_id: int) -> dict:
return self.get(f"/repos/{repo_id}").json()
# ── Pipelines ────────────────────────────────────────────
def list_pipelines(self, repo_id: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/pipelines").json()
def get_pipeline(self, repo_id: int, number: int) -> dict:
return self.get(f"/repos/{repo_id}/pipelines/{number}").json()
def get_logs(self, repo_id: int, number: int, step: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/logs/{number}/{step}").json()
# ── Secrets ──────────────────────────────────────────────
def list_secrets(self, repo_id: int) -> list[dict]:
return self.get(f"/repos/{repo_id}/secrets").json()
def create_secret(self, repo_id: int, body: dict) -> dict:
return self.post(f"/repos/{repo_id}/secrets", json=body).json()
# ── Server ───────────────────────────────────────────────
def version(self) -> dict:
return self.get("/version").json()

View File

@@ -0,0 +1,3 @@
"""Zotero Web API client."""
from .client import ZoteroClient as ZoteroClient

View File

@@ -0,0 +1,86 @@
"""Zotero Web API client."""
from __future__ import annotations
import httpx
from api.clients.base import Client
class ZoteroClient(Client):
"""Client for the Zotero Web API v3.
Parameters
----------
api_key : str
Zotero API key.
user_id : str
Zotero user ID (numeric string).
base_url : str
API base URL.
"""
def __init__(
self,
api_key: str,
user_id: str,
*,
base_url: str = "https://api.zotero.org",
**kw,
) -> None:
super().__init__(base_url, **kw)
self._api_key = api_key
self._user_id = user_id
self._prefix = f"/users/{user_id}"
def _default_headers(self) -> dict[str, str]:
return {
"Zotero-API-Key": self._api_key,
"Zotero-API-Version": "3",
}
# ── Items ────────────────────────────────────────────────
def list_items(self, **params) -> list[dict]:
return self.get(f"{self._prefix}/items", params=params).json()
def get_item(self, key: str) -> dict:
return self.get(f"{self._prefix}/items/{key}").json()
def create_item(self, body: dict) -> dict:
return self.post(f"{self._prefix}/items", json=body).json()
def update_item(self, key: str, body: dict) -> dict:
return self.put(f"{self._prefix}/items/{key}", json=body).json()
def delete_item(self, key: str, *, last_version: int) -> httpx.Response:
return self._request(
"DELETE",
f"{self._prefix}/items/{key}",
headers={"If-Unmodified-Since-Version": str(last_version)},
)
# ── Collections ──────────────────────────────────────────
def list_collections(self, **params) -> list[dict]:
return self.get(f"{self._prefix}/collections", params=params).json()
def get_collection(self, key: str) -> dict:
return self.get(f"{self._prefix}/collections/{key}").json()
def collection_items(self, key: str, **params) -> list[dict]:
return self.get(
f"{self._prefix}/collections/{key}/items",
params=params,
).json()
# ── Tags ─────────────────────────────────────────────────
def list_tags(self, **params) -> list[dict]:
return self.get(f"{self._prefix}/tags", params=params).json()
# ── Search ───────────────────────────────────────────────
def search(self, query: str, **params) -> list[dict]:
params["q"] = query
return self.get(f"{self._prefix}/items", params=params).json()

0
tests/api/__init__.py Normal file
View File

51
tests/api/conftest.py Normal file
View File

@@ -0,0 +1,51 @@
"""Shared fixtures for API client tests."""
from __future__ import annotations
import json
import httpx
import pytest
@pytest.fixture()
def json_response():
"""Factory for httpx.MockTransport handlers returning JSON."""
def _factory(body: object = None, status: int = 200):
payload = json.dumps(body or {}).encode()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status,
content=payload,
headers={"Content-Type": "application/json"},
)
return httpx.MockTransport(handler)
return _factory
@pytest.fixture()
def capture_transport():
"""Transport that records the request and returns a JSON 200."""
class Capture:
def __init__(self):
self.requests: list[httpx.Request] = []
def transport(self, body: object = None, status: int = 200):
payload = json.dumps(body or {}).encode()
def handler(request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return httpx.Response(
status,
content=payload,
headers={"Content-Type": "application/json"},
)
return httpx.MockTransport(handler)
return Capture()

193
tests/api/test_base.py Normal file
View File

@@ -0,0 +1,193 @@
"""Tests for the base Client."""
from __future__ import annotations
import json
import httpx
import pytest
from api.clients.base import ApiError, Client
def _counting_transport(responses):
"""Return a transport that yields pre-defined responses in order."""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
idx = min(call_count["n"], len(responses) - 1)
call_count["n"] += 1
resp = responses[idx]
if isinstance(resp, Exception):
raise resp
status, body = resp
return httpx.Response(
status,
content=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
return httpx.MockTransport(handler), call_count
class TestContextManager:
def test_enter_exit(self, json_response):
transport = json_response()
with Client("http://test", _transport=transport) as c:
assert isinstance(c, Client)
class TestRetry5xx:
def test_retries_on_500_then_succeeds(self):
transport, counts = _counting_transport(
[
(500, {"error": "internal"}),
(200, {"ok": True}),
]
)
c = Client(
"http://test",
retry_interval=0.0,
_transport=transport,
)
resp = c.get("/foo")
assert resp.status_code == 200
assert counts["n"] == 2
def test_raises_after_max_retries(self):
transport, _ = _counting_transport(
[
(500, {"error": "fail"}),
(500, {"error": "fail"}),
(500, {"error": "fail"}),
]
)
c = Client(
"http://test",
max_retries=3,
retry_interval=0.0,
_transport=transport,
)
with pytest.raises(httpx.HTTPStatusError):
c.get("/foo")
class TestRetry429:
def test_retries_on_rate_limit(self):
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
if call_count["n"] == 1:
return httpx.Response(
429,
content=b"{}",
headers={"Retry-After": "0"},
)
return httpx.Response(
200,
content=b'{"ok": true}',
headers={"Content-Type": "application/json"},
)
c = Client(
"http://test",
_transport=httpx.MockTransport(handler),
)
resp = c.get("/foo")
assert resp.status_code == 200
assert call_count["n"] == 2
class TestConnectionError:
def test_retries_on_connect_error(self):
transport, counts = _counting_transport(
[
httpx.ConnectError("refused"),
(200, {"ok": True}),
]
)
c = Client(
"http://test",
retry_interval=0.0,
_transport=transport,
)
resp = c.get("/foo")
assert resp.status_code == 200
assert counts["n"] == 2
def test_raises_api_error_after_exhaustion(self):
transport, _ = _counting_transport(
[
httpx.ConnectError("refused"),
httpx.ConnectError("refused"),
httpx.ConnectError("refused"),
]
)
c = Client(
"http://test",
max_retries=3,
retry_interval=0.0,
_transport=transport,
)
with pytest.raises(ApiError, match="failed after 3 attempts"):
c.get("/foo")
class TestHeaders:
def test_default_headers_merged(self, capture_transport):
cap = capture_transport
class TokenClient(Client):
def _default_headers(self):
return {"Authorization": "Bearer xyz"}
c = TokenClient("http://test", _transport=cap.transport())
c.get("/foo")
assert cap.requests[0].headers["authorization"] == "Bearer xyz"
def test_extra_headers_override(self, capture_transport):
cap = capture_transport
class TokenClient(Client):
def _default_headers(self):
return {"Authorization": "Bearer old"}
c = TokenClient("http://test", _transport=cap.transport())
c.get(
"/foo",
headers={"Authorization": "Bearer new"},
)
assert cap.requests[0].headers["authorization"] == "Bearer new"
class TestAuth401Refresh:
def test_re_authenticates_on_401(self):
call_count = {"n": 0}
refreshed = {"called": False}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
if call_count["n"] == 1:
return httpx.Response(401, content=b"unauth")
return httpx.Response(
200,
content=b'{"ok": true}',
headers={"Content-Type": "application/json"},
)
class RefreshClient(Client):
def _default_headers(self):
tok = "new" if refreshed["called"] else "old"
return {"Authorization": f"Bearer {tok}"}
def authenticate(self):
refreshed["called"] = True
c = RefreshClient(
"http://test",
_transport=httpx.MockTransport(handler),
)
resp = c.get("/foo")
assert resp.status_code == 200
assert refreshed["called"]

53
tests/api/test_gitea.py Normal file
View File

@@ -0,0 +1,53 @@
"""Tests for GiteaClient."""
from __future__ import annotations
from api.clients.gitea import GiteaClient
class TestGiteaAuth:
def test_token_header(self, capture_transport):
cap = capture_transport
c = GiteaClient("my-token", _transport=cap.transport())
c.list_repos()
assert cap.requests[0].headers["authorization"] == "token my-token"
class TestGiteaRoutes:
def test_list_repos(self, capture_transport):
cap = capture_transport
c = GiteaClient("t", _transport=cap.transport([]))
c.list_repos()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/v1/repos/search"
def test_get_repo(self, capture_transport):
cap = capture_transport
c = GiteaClient("t", _transport=cap.transport({}))
c.get_repo("owner", "repo")
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/v1/repos/owner/repo"
def test_create_repo(self, capture_transport):
cap = capture_transport
c = GiteaClient("t", _transport=cap.transport({}))
c.create_repo({"name": "new"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/api/v1/user/repos"
def test_list_orgs(self, capture_transport):
cap = capture_transport
c = GiteaClient("t", _transport=cap.transport([]))
c.list_orgs()
assert cap.requests[0].url.path == "/api/v1/user/orgs"
def test_create_webhook(self, capture_transport):
cap = capture_transport
c = GiteaClient("t", _transport=cap.transport({}))
c.create_webhook("o", "r", {"type": "gitea"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/api/v1/repos/o/r/hooks"

76
tests/api/test_rustfs.py Normal file
View File

@@ -0,0 +1,76 @@
"""Tests for RustFSClient and RustFSAdmin."""
from __future__ import annotations
from api.clients.rustfs import RustFSAdmin, RustFSClient
class TestRustFSClientRoutes:
def test_list_buckets(self, capture_transport):
cap = capture_transport
c = RustFSClient("ak", "sk", _transport=cap.transport())
c.list_buckets()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/"
def test_create_bucket(self, capture_transport):
cap = capture_transport
c = RustFSClient("ak", "sk", _transport=cap.transport())
c.create_bucket("my-bucket")
req = cap.requests[0]
assert req.method == "PUT"
assert req.url.path == "/my-bucket"
def test_put_object(self, capture_transport):
cap = capture_transport
c = RustFSClient("ak", "sk", _transport=cap.transport())
c.put_object("bkt", "key.txt", b"hello")
req = cap.requests[0]
assert req.method == "PUT"
assert req.url.path == "/bkt/key.txt"
def test_get_object(self, capture_transport):
cap = capture_transport
c = RustFSClient("ak", "sk", _transport=cap.transport())
c.get_object("bkt", "file.csv")
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/bkt/file.csv"
def test_delete_object(self, capture_transport):
cap = capture_transport
c = RustFSClient("ak", "sk", _transport=cap.transport())
c.delete_object("bkt", "old.txt")
req = cap.requests[0]
assert req.method == "DELETE"
assert req.url.path == "/bkt/old.txt"
class TestRustFSAdminRoutes:
def test_list_users(self, capture_transport):
cap = capture_transport
c = RustFSAdmin("ak", "sk", _transport=cap.transport({}))
c.list_users()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/minio/v2/iam/users"
def test_add_user(self, capture_transport):
cap = capture_transport
c = RustFSAdmin("ak", "sk", _transport=cap.transport({}))
c.add_user("new-ak", "new-sk")
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/minio/v2/iam/users"
def test_server_info(self, capture_transport):
cap = capture_transport
c = RustFSAdmin("ak", "sk", _transport=cap.transport({"nodes": 1}))
result = c.server_info()
assert result == {"nodes": 1}
assert req_path(cap) == "/minio/v2/cluster/info"
def req_path(cap):
return cap.requests[0].url.path

View File

@@ -0,0 +1,46 @@
"""Tests for WoodpeckerClient."""
from __future__ import annotations
from api.clients.woodpecker import WoodpeckerClient
class TestWoodpeckerAuth:
def test_bearer_header(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("wp-tok", _transport=cap.transport())
c.list_repos()
assert cap.requests[0].headers["authorization"] == "Bearer wp-tok"
class TestWoodpeckerRoutes:
def test_list_repos(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport([]))
c.list_repos()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos"
def test_get_pipeline(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.get_pipeline(42, 7)
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/api/repos/42/pipelines/7"
def test_create_secret(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({}))
c.create_secret(1, {"name": "s", "value": "v"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/api/repos/1/secrets"
def test_version(self, capture_transport):
cap = capture_transport
c = WoodpeckerClient("t", _transport=cap.transport({"version": "2.0"}))
result = c.version()
assert result == {"version": "2.0"}
assert cap.requests[0].url.path == "/api/version"

67
tests/api/test_zotero.py Normal file
View File

@@ -0,0 +1,67 @@
"""Tests for ZoteroClient."""
from __future__ import annotations
from api.clients.zotero import ZoteroClient
class TestZoteroAuth:
def test_api_key_and_version_headers(self, capture_transport):
cap = capture_transport
c = ZoteroClient("zk", "12345", _transport=cap.transport([]))
c.list_items()
h = cap.requests[0].headers
assert h["zotero-api-key"] == "zk"
assert h["zotero-api-version"] == "3"
class TestZoteroRoutes:
def test_list_items(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport([]))
c.list_items()
req = cap.requests[0]
assert req.method == "GET"
assert req.url.path == "/users/99/items"
def test_get_item(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport({}))
c.get_item("ABC123")
assert cap.requests[0].url.path == "/users/99/items/ABC123"
def test_create_item(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport({}))
c.create_item({"itemType": "book"})
req = cap.requests[0]
assert req.method == "POST"
assert req.url.path == "/users/99/items"
def test_list_collections(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport([]))
c.list_collections()
assert cap.requests[0].url.path == "/users/99/collections"
def test_collection_items(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport([]))
c.collection_items("COL1")
assert cap.requests[0].url.path == "/users/99/collections/COL1/items"
def test_search(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport([]))
c.search("machine learning")
req = cap.requests[0]
assert req.url.path == "/users/99/items"
assert "q=machine" in str(req.url)
def test_delete_item_version_header(self, capture_transport):
cap = capture_transport
c = ZoteroClient("k", "99", _transport=cap.transport())
c.delete_item("XYZ", last_version=5)
req = cap.requests[0]
assert req.method == "DELETE"
assert req.headers["if-unmodified-since-version"] == "5"