"""Tests for web UI project registry (#427) and its API evolution (#635).""" import json import sys import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from starlette.testclient import TestClient from webui.app import create_app from webui.project_registry import ( CURRENT_SCHEMA_VERSION, REGISTRY_API_VERSION, SUPPORTED_SCHEMA_VERSIONS, RegistryError, default_registry_path, load_registry, onboarding_summary, project_to_dict, ) from webui.registry_safety import is_forbidden_key _REPO_ROOT = Path(__file__).resolve().parent.parent _API_DOC = _REPO_ROOT / "docs" / "webui-project-registry-api.md" def _valid_project(**overrides): project = { "id": "example", "repo_name": "Example", "gitea_owner": "Org", "remote_host": "https://gitea.example.invalid", "default_branch": "main", "local_checkout_path": ".", "profiles": {"author": "a", "reviewer": "r", "reconciler": "c"}, "workflow_paths": {"skill": "skills/x.md"}, } project.update(overrides) return project def _write_registry(payload) -> Path: with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: json.dump(payload, handle) return Path(handle.name) class RegistryFileCase(unittest.TestCase): """Base class that cleans up temporary registry files.""" def setUp(self): self._temp_paths: list[Path] = [] def tearDown(self): for path in self._temp_paths: path.unlink(missing_ok=True) def write_registry(self, payload) -> Path: path = _write_registry(payload) self._temp_paths.append(path) return path class TestProjectRegistryLoader(RegistryFileCase): def test_default_registry_loads_gitea_tools(self): registry = load_registry() self.assertEqual(registry.version, CURRENT_SCHEMA_VERSION) self.assertEqual(registry.schema_version, CURRENT_SCHEMA_VERSION) self.assertEqual(registry.api_version, REGISTRY_API_VERSION) self.assertEqual(len(registry.projects), 1) project = registry.projects[0] self.assertEqual(project.id, "gitea-tools") self.assertEqual(project.repo_name, "Gitea-Tools") self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting") self.assertEqual(project.repo_full_name, "Scaled-Tech-Consulting/Gitea-Tools") self.assertEqual(project.remote_host, "https://gitea.prgs.cc") self.assertEqual(project.remote_name, "prgs") self.assertEqual(project.status, "active") self.assertEqual(project.profiles["author"], "prgs-author") self.assertEqual(project.profiles["reviewer"], "prgs-reviewer") self.assertEqual(project.profiles["reconciler"], "prgs-reconciler") self.assertIn("skill", project.workflow_paths) self.assertGreaterEqual(len(project.onboarding_checklist), 4) def test_default_registry_onboarding_summary_is_complete(self): summary = onboarding_summary(load_registry().projects[0]) self.assertEqual(summary.total, summary.complete) self.assertEqual(summary.required_outstanding, 0) self.assertTrue(summary.onboarding_complete) def test_version_1_registry_still_loads_with_defaults(self): path = self.write_registry({ "version": 1, "projects": [ _valid_project( onboarding_checklist=[ {"id": "step", "title": "Step", "description": "Do it"} ] ) ], }) registry = load_registry(path) self.assertEqual(registry.schema_version, 1) self.assertIn(1, SUPPORTED_SCHEMA_VERSIONS) project = registry.projects[0] self.assertEqual(project.status, "active") self.assertIsNone(project.remote_name) self.assertIsNone(project.last_seen_health) step = project.onboarding_checklist[0] self.assertEqual(step.state, "pending") self.assertTrue(step.required) self.assertFalse(onboarding_summary(project).onboarding_complete) def test_onboarding_summary_counts_states(self): path = self.write_registry({ "version": 2, "projects": [ _valid_project( onboarding_checklist=[ {"id": "a", "title": "A", "description": "d", "state": "complete"}, {"id": "b", "title": "B", "description": "d", "state": "blocked"}, { "id": "c", "title": "C", "description": "d", "state": "pending", "required": False, }, { "id": "d", "title": "D", "description": "d", "state": "not_applicable", }, ] ) ], }) summary = onboarding_summary(load_registry(path).projects[0]) self.assertEqual(summary.total, 4) self.assertEqual(summary.complete, 1) self.assertEqual(summary.blocked, 1) self.assertEqual(summary.pending, 1) self.assertEqual(summary.not_applicable, 1) # Only the blocked step is both required and outstanding. self.assertEqual(summary.required_outstanding, 1) self.assertFalse(summary.onboarding_complete) def test_last_seen_health_is_parsed_when_present(self): path = self.write_registry({ "version": 2, "projects": [ _valid_project( last_seen_health={ "status": "degraded", "checked_at": "2026-01-01T00:00:00Z", "detail": "daemon restart pending", } ) ], }) health = load_registry(path).projects[0].last_seen_health self.assertIsNotNone(health) self.assertEqual(health.status, "degraded") self.assertEqual(health.checked_at, "2026-01-01T00:00:00Z") def test_registry_rejects_credential_keys(self): path = self.write_registry({ "version": 1, "projects": [_valid_project(id="bad", api_token="redacted-placeholder")], }) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertIn("credential", ctx.exception.remediation.lower()) self.assertEqual(ctx.exception.field_path, "projects[0].api_token") def test_unsupported_version_fails_closed_with_remediation(self): path = self.write_registry({"version": 99, "projects": [_valid_project()]}) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertIn("unsupported registry version", ctx.exception.message) self.assertIn(str(CURRENT_SCHEMA_VERSION), ctx.exception.remediation) self.assertEqual(ctx.exception.field_path, "version") def test_missing_required_field_fails_closed(self): broken = _valid_project() del broken["default_branch"] path = self.write_registry({"version": 2, "projects": [broken]}) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertIn("default_branch", ctx.exception.message) self.assertEqual(ctx.exception.field_path, "projects[0]") def test_unknown_status_fails_closed(self): path = self.write_registry({ "version": 2, "projects": [_valid_project(status="mystery")], }) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertEqual(ctx.exception.field_path, "projects[0].status") self.assertIn("active", ctx.exception.remediation) def test_unknown_onboarding_state_fails_closed(self): path = self.write_registry({ "version": 2, "projects": [ _valid_project( onboarding_checklist=[ {"id": "a", "title": "A", "description": "d", "state": "almost"} ] ) ], }) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertEqual( ctx.exception.field_path, "projects[0].onboarding_checklist[0].state", ) def test_missing_profile_role_fails_closed(self): path = self.write_registry({ "version": 2, "projects": [_valid_project(profiles={"author": "a", "reviewer": "r"})], }) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertEqual(ctx.exception.field_path, "projects[0].profiles.reconciler") def test_empty_projects_fails_closed(self): path = self.write_registry({"version": 2, "projects": []}) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertEqual(ctx.exception.field_path, "projects") def test_invalid_json_fails_closed_with_location(self): with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: handle.write("{not json") path = Path(handle.name) self._temp_paths.append(path) with self.assertRaises(RegistryError) as ctx: load_registry(path) self.assertIn("not valid JSON", ctx.exception.message) self.assertIn("line", ctx.exception.remediation) def test_missing_file_fails_closed(self): missing = Path(tempfile.gettempdir()) / "webui-registry-does-not-exist.json" with self.assertRaises(RegistryError) as ctx: load_registry(missing) self.assertIn("could not be read", ctx.exception.message) def test_default_registry_path_points_at_packaged_data(self): path = default_registry_path() self.assertTrue(path.name == "projects.registry.json") self.assertTrue(path.parent.name == "data") class TestProjectRegistryRoutes(unittest.TestCase): def setUp(self): self.client = TestClient(create_app()) def test_projects_page_lists_gitea_tools(self): response = self.client.get("/projects") self.assertEqual(response.status_code, 200) self.assertIn("Gitea-Tools", response.text) self.assertIn("Scaled-Tech-Consulting", response.text) self.assertIn("prgs-author", response.text) self.assertNotIn("child issue", response.text.lower()) def test_projects_page_shows_status_and_progress(self): response = self.client.get("/projects") self.assertIn("Status", response.text) self.assertIn("Onboarding", response.text) self.assertIn("4/4 complete", response.text) def test_project_detail_renders_checklist(self): response = self.client.get("/projects/gitea-tools") self.assertEqual(response.status_code, 200) self.assertIn("Onboarding checklist", response.text) self.assertIn("Configure execution profiles", response.text) self.assertIn("branches/", response.text) self.assertIn("Complete", response.text) self.assertIn("required outstanding 0", response.text) def test_project_detail_404(self): response = self.client.get("/projects/unknown-repo") self.assertEqual(response.status_code, 404) def test_api_projects_alias_stays_compatible(self): response = self.client.get("/api/projects") self.assertEqual(response.status_code, 200) data = response.json() # #427 consumers keep these keys. self.assertEqual(data["version"], CURRENT_SCHEMA_VERSION) self.assertIn("source_path", data) self.assertEqual(len(data["projects"]), 1) self.assertEqual(data["projects"][0]["id"], "gitea-tools") self.assertIn("onboarding_checklist", data["projects"][0]) def test_api_v1_projects_payload(self): response = self.client.get("/api/v1/projects") self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["api_version"], REGISTRY_API_VERSION) self.assertEqual(data["schema_version"], CURRENT_SCHEMA_VERSION) self.assertEqual(data["project_count"], 1) self.assertEqual(data["source"]["kind"], "file") self.assertTrue(data["source"]["inventory_complete"]) project = data["projects"][0] self.assertEqual(project["status"], "active") self.assertEqual(project["remote_name"], "prgs") self.assertEqual( project["repo_full_name"], "Scaled-Tech-Consulting/Gitea-Tools" ) self.assertTrue(project["onboarding_summary"]["onboarding_complete"]) self.assertEqual(project["onboarding_checklist"][0]["state"], "complete") self.assertIsNone(project["last_seen_health"]) def test_api_v1_project_detail(self): response = self.client.get("/api/v1/projects/gitea-tools") self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["api_version"], REGISTRY_API_VERSION) self.assertEqual(data["project"]["id"], "gitea-tools") self.assertEqual(data["source"]["kind"], "file") def test_api_v1_project_detail_missing_fails_closed(self): response = self.client.get("/api/v1/projects/not-registered") self.assertEqual(response.status_code, 404) data = response.json() self.assertEqual(data["error"], "project_not_found") self.assertEqual(data["project_id"], "not-registered") self.assertIn("gitea-tools", data["known_project_ids"]) self.assertIn("remediation", data) def test_api_v1_projects_is_read_only(self): response = self.client.post("/api/v1/projects", json={}) self.assertEqual(response.status_code, 405) self.assertEqual(response.json()["error"], "read-only-mvp") def test_project_to_dict_is_json_safe(self): registry = load_registry() dto = project_to_dict(registry.projects[0]) encoded = json.dumps(dto) self.assertIn("gitea-tools", encoded) # Prose may mention tokens; no serialized *key* may look like a secret. for key in dto: with self.subTest(key=key): self.assertFalse(is_forbidden_key(key)) class TestInvalidRegistryFailsClosedOverHttp(RegistryFileCase): def setUp(self): super().setUp() self.path = self.write_registry({"version": 42, "projects": []}) self.client = TestClient(create_app()) def _with_bad_registry(self, url: str): import os from unittest import mock with mock.patch.dict( os.environ, {"WEBUI_PROJECT_REGISTRY": str(self.path)}, clear=False ): return self.client.get(url) def test_api_v1_reports_actionable_error(self): response = self._with_bad_registry("/api/v1/projects") self.assertEqual(response.status_code, 500) data = response.json() self.assertEqual(data["error"], "registry_invalid") self.assertIn("unsupported registry version", data["detail"]) self.assertTrue(data["remediation"]) self.assertEqual(data["field_path"], "version") def test_unversioned_alias_reports_actionable_error(self): response = self._with_bad_registry("/api/projects") self.assertEqual(response.status_code, 500) self.assertEqual(response.json()["error"], "registry_invalid") def test_html_page_reports_actionable_error(self): response = self._with_bad_registry("/projects") self.assertEqual(response.status_code, 500) self.assertIn("Project registry unavailable", response.text) self.assertIn("Remediation", response.text) class TestProjectRegistryApiDocs(unittest.TestCase): def test_api_contract_is_documented(self): self.assertTrue(_API_DOC.is_file(), f"missing {_API_DOC}") text = _API_DOC.read_text(encoding="utf-8") for token in ( "/api/v1/projects", "/api/v1/projects/{project_id}", "/api/projects", "onboarding_summary", "last_seen_health", "registry_invalid", "#635", ): with self.subTest(token=token): self.assertIn(token, text) def test_route_table_lists_versioned_routes(self): local_dev = (_REPO_ROOT / "docs" / "webui-local-dev.md").read_text( encoding="utf-8" ) self.assertIn("/api/v1/projects", local_dev) self.assertIn("webui-project-registry-api.md", local_dev) if __name__ == "__main__": unittest.main()