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.
641 lines
19 KiB
Python
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()
|