crafting-table/mcp/tests/test_client.py
Cobb Hayes b335405c02 Public-flip audit: generalize internal hosts/paths + drop Sulkta-internal refs
URLs, mount paths, and LAN host bindings parameterized via env or relative paths
so the repo stands up from a clean clone anywhere. Drop cross-codebase refs
("mirrors clawdforge's pattern"), Sulkta-Coop client/merchant test fixtures,
and audit-changelog scaffolding from comments. README terser, technical content
preserved.
2026-05-27 11:25:47 -07:00

641 lines
19 KiB
Python

"""HTTP-level tests for ``crafting_table_mcp.client.CraftingTableClient``.
We stub the wire layer with ``responses`` and assert each method:
- hits the correct endpoint with the correct shape (verb, path, body, params)
- raises the right exception class for each well-known status code
- handles malformed JSON / unexpected response types without crashing
"""
from __future__ import annotations
import json
import unittest
import responses
from crafting_table_mcp.client import (
CraftingTableAPIError,
CraftingTableAuthError,
CraftingTableClient,
CraftingTableError,
CraftingTableNotFoundError,
CraftingTableTransportError,
)
BASE_URL = "http://crafting-table.test:8810"
TOKEN = "ct_test_token_xxxxxxxx"
def _client() -> CraftingTableClient:
return CraftingTableClient(base_url=BASE_URL, token=TOKEN, timeout_secs=10)
class TestConstructorValidation(unittest.TestCase):
def test_requires_base_url(self) -> None:
with self.assertRaises(ValueError):
CraftingTableClient(base_url="", token=TOKEN)
def test_requires_token(self) -> None:
with self.assertRaises(ValueError):
CraftingTableClient(base_url=BASE_URL, token="")
def test_strips_trailing_slash(self) -> None:
c = CraftingTableClient(base_url=BASE_URL + "/", token=TOKEN)
self.assertEqual(c.base_url, BASE_URL)
class TestHealthz(unittest.TestCase):
@responses.activate
def test_healthz_ok(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/healthz",
json={"ok": True, "db": "ok", "version": "0.1.0"},
status=200,
)
c = _client()
try:
r = c.healthz()
finally:
c.close()
self.assertEqual(r["ok"], True)
self.assertEqual(r["version"], "0.1.0")
@responses.activate
def test_healthz_sends_bearer(self) -> None:
captured: dict = {}
def cb(request):
captured["auth"] = request.headers.get("Authorization")
return (200, {}, json.dumps({"ok": True}))
responses.add_callback(responses.GET, f"{BASE_URL}/healthz", callback=cb)
c = _client()
try:
c.healthz()
finally:
c.close()
self.assertEqual(captured["auth"], f"Bearer {TOKEN}")
class TestProjects(unittest.TestCase):
@responses.activate
def test_list_projects_returns_array(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={
"ok": True,
"projects": [
{"name": "alpha", "git_url": "..."},
{"name": "bravo", "git_url": "..."},
],
},
status=200,
)
c = _client()
try:
rows = c.list_projects()
finally:
c.close()
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["name"], "alpha")
@responses.activate
def test_list_projects_handles_empty(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"ok": True, "projects": []},
status=200,
)
c = _client()
try:
rows = c.list_projects()
finally:
c.close()
self.assertEqual(rows, [])
@responses.activate
def test_get_project_path_and_unwrap(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects/alpha",
json={"ok": True, "project": {"name": "alpha", "git_url": "..."}},
status=200,
)
c = _client()
try:
p = c.get_project("alpha")
finally:
c.close()
self.assertEqual(p["name"], "alpha")
def test_get_project_rejects_empty_name(self) -> None:
c = _client()
try:
with self.assertRaises(ValueError):
c.get_project("")
finally:
c.close()
@responses.activate
def test_get_project_url_quoted(self) -> None:
# Slash in a project name shouldn't break the URL — the client
# quotes it. Server-side validation is a separate concern; the
# client must not produce malformed paths.
captured: dict = {}
def cb(request):
captured["url"] = request.url
return (200, {}, json.dumps({"ok": True, "project": {"name": "x"}}))
responses.add_callback(
responses.GET,
f"{BASE_URL}/projects/weird%2Fname",
callback=cb,
)
c = _client()
try:
c.get_project("weird/name")
finally:
c.close()
self.assertIn("weird%2Fname", captured["url"])
@responses.activate
def test_register_project_posts_body(self) -> None:
captured: dict = {}
def cb(request):
captured["body"] = json.loads(request.body)
return (
200,
{},
json.dumps(
{"ok": True, "project": {"name": "alpha", "git_url": "x"}}
),
)
responses.add_callback(responses.POST, f"{BASE_URL}/projects", callback=cb)
c = _client()
try:
r = c.register_project(
{"name": "alpha", "git_url": "http://example/x.git"}
)
finally:
c.close()
self.assertEqual(captured["body"]["name"], "alpha")
self.assertEqual(r["name"], "alpha")
def test_register_project_requires_name(self) -> None:
c = _client()
try:
with self.assertRaises(ValueError):
c.register_project({"git_url": "..."})
finally:
c.close()
def test_register_project_requires_dict(self) -> None:
c = _client()
try:
with self.assertRaises(ValueError):
c.register_project("not a dict") # type: ignore[arg-type]
finally:
c.close()
@responses.activate
def test_update_project_uses_put(self) -> None:
responses.add(
responses.PUT,
f"{BASE_URL}/projects/alpha",
json={"ok": True, "project": {"name": "alpha", "git_url": "y"}},
status=200,
)
c = _client()
try:
r = c.update_project("alpha", {"name": "alpha", "git_url": "y"})
finally:
c.close()
self.assertEqual(r["git_url"], "y")
@responses.activate
def test_delete_project(self) -> None:
responses.add(
responses.DELETE,
f"{BASE_URL}/projects/alpha",
json={"ok": True},
status=200,
)
c = _client()
try:
r = c.delete_project("alpha")
finally:
c.close()
self.assertEqual(r, {"ok": True})
class TestJobs(unittest.TestCase):
@responses.activate
def test_create_job_posts_recipe(self) -> None:
captured: dict = {}
def cb(request):
captured["body"] = json.loads(request.body)
return (
200,
{},
json.dumps(
{
"ok": True,
"job_id": "j-1",
"status": "queued",
"job": {"id": "j-1"},
}
),
)
responses.add_callback(
responses.POST,
f"{BASE_URL}/projects/alpha/jobs",
callback=cb,
)
c = _client()
try:
r = c.create_job(
project="alpha",
recipe="audit",
subproject="clients/python",
branch="dev",
)
finally:
c.close()
self.assertEqual(captured["body"]["recipe"], "audit")
self.assertEqual(captured["body"]["subproject"], "clients/python")
self.assertEqual(captured["body"]["branch"], "dev")
self.assertEqual(r["job_id"], "j-1")
self.assertEqual(r["status"], "queued")
@responses.activate
def test_create_job_omits_optional_fields(self) -> None:
"""When subproject/branch are None, they MUST NOT appear in the body.
FastAPI accepts the missing-key form as the default, but a literal
``null`` would be rejected by Pydantic.
"""
captured: dict = {}
def cb(request):
captured["body"] = json.loads(request.body)
return (
200,
{},
json.dumps(
{
"ok": True,
"job_id": "j-2",
"status": "queued",
"job": {"id": "j-2"},
}
),
)
responses.add_callback(
responses.POST,
f"{BASE_URL}/projects/alpha/jobs",
callback=cb,
)
c = _client()
try:
c.create_job(project="alpha", recipe="build")
finally:
c.close()
self.assertNotIn("subproject", captured["body"])
self.assertNotIn("branch", captured["body"])
def test_create_job_rejects_empty_project(self) -> None:
c = _client()
try:
with self.assertRaises(ValueError):
c.create_job(project="", recipe="audit")
finally:
c.close()
def test_create_job_rejects_empty_recipe(self) -> None:
c = _client()
try:
with self.assertRaises(ValueError):
c.create_job(project="alpha", recipe="")
finally:
c.close()
@responses.activate
def test_get_job(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/j-1",
json={
"ok": True,
"job": {"id": "j-1", "status": "succeeded", "exit_code": 0},
"log_tail": ["line a", "line b"],
},
status=200,
)
c = _client()
try:
r = c.get_job("j-1")
finally:
c.close()
self.assertEqual(r["job"]["status"], "succeeded")
self.assertEqual(r["log_tail"], ["line a", "line b"])
@responses.activate
def test_list_jobs_with_filters(self) -> None:
captured: dict = {}
def cb(request):
captured["params"] = dict(request.params) if hasattr(request, "params") else {}
# responses attaches the parsed querystring on request.url.
# Easier: just store the URL and parse later.
captured["url"] = request.url
return (
200,
{},
json.dumps({"ok": True, "jobs": [{"id": "j-1"}, {"id": "j-2"}]}),
)
responses.add_callback(responses.GET, f"{BASE_URL}/jobs", callback=cb)
c = _client()
try:
rows = c.list_jobs(project="alpha", status="succeeded", limit=10)
finally:
c.close()
self.assertEqual(len(rows), 2)
# Verify the params went out on the URL.
self.assertIn("project=alpha", captured["url"])
self.assertIn("status=succeeded", captured["url"])
self.assertIn("limit=10", captured["url"])
@responses.activate
def test_list_jobs_no_filters(self) -> None:
captured: dict = {}
def cb(request):
captured["url"] = request.url
return (200, {}, json.dumps({"ok": True, "jobs": []}))
responses.add_callback(responses.GET, f"{BASE_URL}/jobs", callback=cb)
c = _client()
try:
c.list_jobs()
finally:
c.close()
# No project / status; only limit (default 50) goes out.
self.assertNotIn("project=", captured["url"])
self.assertNotIn("status=", captured["url"])
self.assertIn("limit=50", captured["url"])
@responses.activate
def test_get_findings(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/j-1/findings",
json={
"ok": True,
"findings": [
{
"id": 1,
"kind": "lint",
"severity": "warn",
"message": "x",
}
],
},
status=200,
)
c = _client()
try:
rows = c.get_findings("j-1")
finally:
c.close()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["kind"], "lint")
@responses.activate
def test_get_findings_empty(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/j-1/findings",
json={"ok": True, "findings": []},
status=200,
)
c = _client()
try:
rows = c.get_findings("j-1")
finally:
c.close()
self.assertEqual(rows, [])
@responses.activate
def test_get_log_returns_text(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/j-1/log",
body="line 1\nline 2\nline 3\n",
content_type="text/plain",
status=200,
)
c = _client()
try:
text = c.get_log("j-1")
finally:
c.close()
self.assertEqual(text, "line 1\nline 2\nline 3\n")
class TestExceptionMapping(unittest.TestCase):
"""Each well-known status code should raise its dedicated subclass."""
@responses.activate
def test_404_raises_not_found(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects/missing",
json={"detail": "project not found"},
status=404,
)
c = _client()
try:
with self.assertRaises(CraftingTableNotFoundError) as ctx:
c.get_project("missing")
finally:
c.close()
self.assertEqual(ctx.exception.status_code, 404)
@responses.activate
def test_401_raises_auth(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"detail": "missing bearer"},
status=401,
)
c = _client()
try:
with self.assertRaises(CraftingTableAuthError) as ctx:
c.list_projects()
finally:
c.close()
self.assertEqual(ctx.exception.status_code, 401)
@responses.activate
def test_403_raises_auth(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"detail": "bad token"},
status=403,
)
c = _client()
try:
with self.assertRaises(CraftingTableAuthError):
c.list_projects()
finally:
c.close()
@responses.activate
def test_500_raises_api_error(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"detail": "boom"},
status=500,
)
c = _client()
try:
with self.assertRaises(CraftingTableAPIError) as ctx:
c.list_projects()
finally:
c.close()
self.assertEqual(ctx.exception.status_code, 500)
# The base error class should NOT be a NotFound or Auth.
self.assertNotIsInstance(ctx.exception, CraftingTableNotFoundError)
self.assertNotIsInstance(ctx.exception, CraftingTableAuthError)
@responses.activate
def test_409_raises_api_error(self) -> None:
responses.add(
responses.POST,
f"{BASE_URL}/projects",
json={"detail": "project already exists; use PUT to update"},
status=409,
)
c = _client()
try:
with self.assertRaises(CraftingTableAPIError) as ctx:
c.register_project({"name": "dup", "git_url": "x"})
finally:
c.close()
self.assertEqual(ctx.exception.status_code, 409)
self.assertIn("already exists", ctx.exception.message)
@responses.activate
def test_get_log_404_raises_not_found(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/missing/log",
json={"detail": "log file not present"},
status=404,
)
c = _client()
try:
with self.assertRaises(CraftingTableNotFoundError):
c.get_log("missing")
finally:
c.close()
@responses.activate
def test_get_log_401_raises_auth(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/jobs/x/log",
body="bad token",
status=401,
)
c = _client()
try:
with self.assertRaises(CraftingTableAuthError):
c.get_log("x")
finally:
c.close()
class TestTransportErrors(unittest.TestCase):
"""Network errors (no HTTP response) raise CraftingTableTransportError."""
def test_unreachable_host(self) -> None:
# Use a port that's almost certainly closed to force a TCP refusal.
# Tests don't require the real port-8810 service to be up.
c = CraftingTableClient(
base_url="http://127.0.0.1:1",
token=TOKEN,
timeout_secs=2,
)
try:
with self.assertRaises(CraftingTableTransportError):
c.healthz()
finally:
c.close()
class TestUnexpectedResponseShapes(unittest.TestCase):
"""Bad JSON / wrong types should raise CraftingTableError, not crash."""
@responses.activate
def test_healthz_non_dict(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/healthz",
json=["unexpected", "list"],
status=200,
)
c = _client()
try:
with self.assertRaises(CraftingTableError):
c.healthz()
finally:
c.close()
@responses.activate
def test_list_projects_missing_key(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"ok": True}, # no 'projects' key
status=200,
)
c = _client()
try:
rows = c.list_projects()
finally:
c.close()
self.assertEqual(rows, []) # missing-key path returns []
@responses.activate
def test_list_projects_wrong_type(self) -> None:
responses.add(
responses.GET,
f"{BASE_URL}/projects",
json={"ok": True, "projects": "not a list"},
status=200,
)
c = _client()
try:
with self.assertRaises(CraftingTableError):
c.list_projects()
finally:
c.close()
if __name__ == "__main__": # pragma: no cover
unittest.main()