diff --git a/testjam-client/testjam_client/cli/__init__.py b/testjam-client/testjam_client/cli/__init__.py index c0f7296..965f02c 100644 --- a/testjam-client/testjam_client/cli/__init__.py +++ b/testjam-client/testjam_client/cli/__init__.py @@ -5,6 +5,7 @@ import httpx import typer +from testjam_client.cli.commands import about as about_commands from testjam_client.cli.commands import auth as auth_commands from testjam_client.cli.commands import backup as backup_commands from testjam_client.cli.commands import bugs as bug_commands @@ -133,3 +134,4 @@ def repl_command(context: typer.Context) -> None: app.add_typer(run_commands.build_app()) app.add_typer(bug_commands.build_app()) app.add_typer(backup_commands.build_app()) +app.add_typer(about_commands.build_app()) diff --git a/testjam-client/testjam_client/cli/commands/about.py b/testjam-client/testjam_client/cli/commands/about.py new file mode 100644 index 0000000..ee5a45e --- /dev/null +++ b/testjam-client/testjam_client/cli/commands/about.py @@ -0,0 +1,75 @@ +"""`testjam about` — show client + server build metadata.""" +from __future__ import annotations + +from dataclasses import asdict +from importlib.metadata import PackageNotFoundError, version + +import httpx +import typer + +from testjam_client.cli import output, runtime +from testjam_client.cli.state import CliState + + +PACKAGE_NAME = "testjam-client" +UNKNOWN = "unknown" + + +def build_app() -> typer.Typer: + app = typer.Typer( + name="about", + help="Show client + server build metadata.", + no_args_is_help=False, + invoke_without_command=True, + ) + app.callback(invoke_without_command=True)(show_about) + return app + + +def show_about(context: typer.Context) -> None: + if context.invoked_subcommand is not None: + return + state: CliState = context.obj + payload = { + "client_version": _client_version(), + "server_url": state.profile.get("url"), + "server_version": UNKNOWN, + "server_ref": UNKNOWN, + "server_commit": UNKNOWN, + "server_built_at": UNKNOWN, + "server_status": "unreachable", + } + server = _fetch_server(state) + if server is not None: + payload.update({ + "server_version": server["version"], + "server_ref": server["ref"], + "server_commit": server["commit"], + "server_built_at": server["built_at"], + "server_status": "ok", + }) + columns = [ + "client_version", + "server_url", + "server_status", + "server_version", + "server_ref", + "server_commit", + "server_built_at", + ] + output.render(payload, columns=columns, json_mode=state.json_output) + + +def _fetch_server(state: CliState) -> dict | None: + try: + with runtime.build_client(state) as client: + return asdict(client.about.get()) + except (httpx.HTTPError, typer.BadParameter): + return None + + +def _client_version() -> str: + try: + return version(PACKAGE_NAME) + except PackageNotFoundError: + return UNKNOWN diff --git a/testjam-client/testjam_client/client.py b/testjam-client/testjam_client/client.py index 05bd86d..a20a01b 100644 --- a/testjam-client/testjam_client/client.py +++ b/testjam-client/testjam_client/client.py @@ -17,6 +17,7 @@ import httpx from testjam_client.errors import raise_for_status +from testjam_client.resources.about import AboutResource from testjam_client.resources.backup import BackupResource from testjam_client.resources.bugs import BugsResource from testjam_client.resources.cases import CasesResource @@ -85,6 +86,7 @@ def __init__( self.mentions = MentionsResource(self) self.public_reports = PublicReportsResource(self) self.backup = BackupResource(self) + self.about = AboutResource(self) def login(self, username: str, password: str) -> str: response = self._http.request( diff --git a/testjam-client/testjam_client/resources/about.py b/testjam-client/testjam_client/resources/about.py new file mode 100644 index 0000000..c064ec0 --- /dev/null +++ b/testjam-client/testjam_client/resources/about.py @@ -0,0 +1,25 @@ +"""Server `/about` — build metadata for the connected instance. + +Mirrors the FastAPI response. Public endpoint, no auth required, so this +resource works against an unauthenticated client too (useful for +compatibility checks before configuring credentials). +""" +from __future__ import annotations + +from dataclasses import dataclass + +from testjam_client.resources._base import Resource + + +@dataclass(frozen=True) +class ServerAbout: + version: str + ref: str + commit: str + built_at: str + + +class AboutResource(Resource): + def get(self) -> ServerAbout: + response = self._request("GET", "/about") + return ServerAbout(**response.json()) diff --git a/testjam-client/tests/test_about.py b/testjam-client/tests/test_about.py new file mode 100644 index 0000000..50dd0e6 --- /dev/null +++ b/testjam-client/tests/test_about.py @@ -0,0 +1,56 @@ +"""SDK AboutResource — maps `/about` JSON onto the ServerAbout dataclass. + +Uses a tiny `httpx.MockTransport` so the test stays decoupled from the +backend in-process app — `/about` is public, so there is nothing to +exercise on the server side beyond the JSON shape. +""" +from __future__ import annotations + +import httpx + +from testjam_client import TestjamClient +from testjam_client.resources.about import ServerAbout + + +SAMPLE = { + "version": "1.0.0", + "ref": "v1.0.0", + "commit": "abc1234", + "built_at": "2026-05-25T12:00:00Z", +} + + +def test_get_returns_serveraobout_dataclass(): + sdk = _client_with_about(SAMPLE) + + result = sdk.about.get() + + assert isinstance(result, ServerAbout) + assert result == ServerAbout(**SAMPLE) + + +def test_get_does_not_require_authentication(): + sdk = _client_with_about(SAMPLE) + assert "Authorization" not in sdk._http.headers + assert "X-API-Key" not in sdk._http.headers + + sdk.about.get() + + +def test_dataclass_is_immutable(): + payload = ServerAbout(**SAMPLE) + try: + payload.version = "2.0.0" + except Exception: + return + raise AssertionError("ServerAbout should be frozen") + + +def _client_with_about(payload): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/about") + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(handler) + http = httpx.Client(base_url="http://testserver/api/v1", transport=transport) + return TestjamClient(http=http) diff --git a/testjam-client/tests/test_cli_about.py b/testjam-client/tests/test_cli_about.py new file mode 100644 index 0000000..a191fba --- /dev/null +++ b/testjam-client/tests/test_cli_about.py @@ -0,0 +1,104 @@ +"""CLI smoke for `testjam about`. + +Patches `runtime.build_client` so the command receives a fake SDK whose +`.about.get()` returns a canned `ServerAbout`. No backend dependency. +""" +from __future__ import annotations + +import json +from contextlib import nullcontext + +import httpx +import pytest +from typer.testing import CliRunner + +from testjam_client.cli import app +from testjam_client.resources.about import ServerAbout + + +runner = CliRunner() + + +@pytest.fixture +def cli_config(tmp_path, monkeypatch): + config_file = tmp_path / "config.toml" + monkeypatch.setenv("TESTJAM_CONFIG", str(config_file)) + config_file.write_text('[profiles.default]\nurl = "http://testserver/api/v1"\n') + + +def test_about_prints_client_and_server_metadata(cli_config, monkeypatch): + monkeypatch.setattr( + "testjam_client.cli.commands.about.runtime.build_client", + lambda state: _FakeClient(_sample()), + ) + + result = runner.invoke(app, ["--json", "about"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["client_version"] + assert payload["server_url"] == "http://testserver/api/v1" + assert payload["server_status"] == "ok" + assert payload["server_version"] == "1.0.0" + assert payload["server_ref"] == "v1.0.0" + assert payload["server_commit"] == "abc1234" + assert payload["server_built_at"] == "2026-05-25T12:00:00Z" + + +def test_about_falls_back_to_unreachable_on_network_error(cli_config, monkeypatch): + def boom(state): + raise httpx.ConnectError("nope") + + monkeypatch.setattr( + "testjam_client.cli.commands.about.runtime.build_client", + boom, + ) + + result = runner.invoke(app, ["--json", "about"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["server_status"] == "unreachable" + assert payload["server_version"] == "unknown" + assert payload["client_version"] + + +def test_about_falls_back_when_no_profile_url(tmp_path, monkeypatch): + config_file = tmp_path / "config.toml" + monkeypatch.setenv("TESTJAM_CONFIG", str(config_file)) + config_file.write_text('[profiles.default]\n') + + result = runner.invoke(app, ["--json", "about"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["server_status"] == "unreachable" + assert payload["server_url"] is None + + +def _sample() -> ServerAbout: + return ServerAbout( + version="1.0.0", + ref="v1.0.0", + commit="abc1234", + built_at="2026-05-25T12:00:00Z", + ) + + +class _FakeAbout: + def __init__(self, payload: ServerAbout) -> None: + self._payload = payload + + def get(self) -> ServerAbout: + return self._payload + + +class _FakeClient: + def __init__(self, payload: ServerAbout) -> None: + self.about = _FakeAbout(payload) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None