"""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()