Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions testjam-client/testjam_client/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
75 changes: 75 additions & 0 deletions testjam-client/testjam_client/cli/commands/about.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions testjam-client/testjam_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions testjam-client/testjam_client/resources/about.py
Original file line number Diff line number Diff line change
@@ -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())
56 changes: 56 additions & 0 deletions testjam-client/tests/test_about.py
Original file line number Diff line number Diff line change
@@ -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)
104 changes: 104 additions & 0 deletions testjam-client/tests/test_cli_about.py
Original file line number Diff line number Diff line change
@@ -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
Loading