Add project register, unregister, and rename endpoints
This commit is contained in:
+88
-2
@@ -343,6 +343,29 @@ def auto_register_default():
|
|||||||
print(f"[init] Registered default project: {path}")
|
print(f"[init] Registered default project: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def register_project(raw_path: str, raw_name: str | None = None) -> tuple:
|
||||||
|
"""Register a project for raw_path. Returns (payload, status).
|
||||||
|
|
||||||
|
Paths without an .md suffix get backlog.md appended; a missing file is
|
||||||
|
created from the blank template; the name defaults to the parent dir name.
|
||||||
|
"""
|
||||||
|
path = Path(raw_path).expanduser().resolve()
|
||||||
|
if not path.name.endswith(".md"):
|
||||||
|
path = path / "backlog.md"
|
||||||
|
name = safe_project_name(raw_name or path.parent.name)
|
||||||
|
if not name:
|
||||||
|
return {"ok": False, "error": "Invalid project name"}, 400
|
||||||
|
registry = CONFIG.load_registry()
|
||||||
|
if name in registry:
|
||||||
|
return {"ok": False, "error": f"Project '{name}' already registered"}, 409
|
||||||
|
if not path.exists():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(build_markdown("", BLANK_HISTORY), encoding="utf-8")
|
||||||
|
registry[name] = {"path": str(path), "name": name}
|
||||||
|
CONFIG.save_registry(registry)
|
||||||
|
return {"ok": True, "name": name, "path": str(path)}, 200
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HTTP Handler
|
# HTTP Handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -358,7 +381,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
@@ -418,7 +441,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_OPTIONS(self):
|
def do_OPTIONS(self):
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@@ -554,6 +577,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
|
|
||||||
|
if path == "/api/projects":
|
||||||
|
body = self._read_json_body_or_400()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
raw_path = body.get("path")
|
||||||
|
if not isinstance(raw_path, str) or not raw_path:
|
||||||
|
self._json_response({"ok": False, "error": "Missing 'path'"}, 400)
|
||||||
|
return
|
||||||
|
raw_name = body.get("name")
|
||||||
|
if raw_name is not None and not isinstance(raw_name, str):
|
||||||
|
self._json_response({"ok": False, "error": "Invalid project name"}, 400)
|
||||||
|
return
|
||||||
|
payload, status = register_project(raw_path, raw_name)
|
||||||
|
self._json_response(payload, status)
|
||||||
|
return
|
||||||
|
|
||||||
if path.startswith("/api/projects/"):
|
if path.startswith("/api/projects/"):
|
||||||
name, action = self._project_route_parts(path)
|
name, action = self._project_route_parts(path)
|
||||||
body = self._read_json_body_or_400()
|
body = self._read_json_body_or_400()
|
||||||
@@ -562,6 +601,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
paths = self._resolve_project_or_404(name)
|
paths = self._resolve_project_or_404(name)
|
||||||
if paths is None:
|
if paths is None:
|
||||||
return
|
return
|
||||||
|
if action == "rename":
|
||||||
|
new_name = safe_project_name(body.get("newName", ""))
|
||||||
|
if not new_name:
|
||||||
|
self._json_response({"ok": False, "error": "Invalid project name"}, 400)
|
||||||
|
return
|
||||||
|
registry = CONFIG.load_registry()
|
||||||
|
if new_name in registry:
|
||||||
|
self._json_response(
|
||||||
|
{"ok": False, "error": f"Project '{new_name}' already registered"}, 409)
|
||||||
|
return
|
||||||
|
entry = registry.pop(name)
|
||||||
|
registry[new_name] = {"path": entry["path"], "name": new_name}
|
||||||
|
CONFIG.save_registry(registry)
|
||||||
|
self._json_response({"ok": True, "name": new_name})
|
||||||
|
return
|
||||||
if action == "backlog":
|
if action == "backlog":
|
||||||
result = write_master_at(body.get("content", ""), paths)
|
result = write_master_at(body.get("content", ""), paths)
|
||||||
self._json_response(result)
|
self._json_response(result)
|
||||||
@@ -671,6 +725,38 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
self._json_response({"error": "Not found"}, 404)
|
self._json_response({"error": "Not found"}, 404)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if path.startswith("/api/projects/"):
|
||||||
|
name, action = self._project_route_parts(path)
|
||||||
|
if action:
|
||||||
|
self._json_response({"error": "Not found"}, 404)
|
||||||
|
return
|
||||||
|
registry = CONFIG.load_registry()
|
||||||
|
if name not in registry:
|
||||||
|
self._json_response({"error": "Project not found"}, 404)
|
||||||
|
return
|
||||||
|
if len(registry) == 1:
|
||||||
|
self._json_response({"error": "At least one project must remain registered"}, 400)
|
||||||
|
return
|
||||||
|
master = Path(registry[name]["path"]).resolve()
|
||||||
|
del registry[name]
|
||||||
|
CONFIG.save_registry(registry)
|
||||||
|
result = {"ok": True, "name": name, "deleted": False}
|
||||||
|
if qs.get("delete_file", [""])[0] == "true" and master.exists():
|
||||||
|
try:
|
||||||
|
master.unlink()
|
||||||
|
result["deleted"] = True
|
||||||
|
except OSError as e:
|
||||||
|
result["warning"] = f"Could not delete file: {e}"
|
||||||
|
self._json_response(result)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json_response({"error": "Not found"}, 404)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main
|
# Main
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Two layers:
|
|||||||
import http.client
|
import http.client
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
@@ -420,5 +421,254 @@ class TestProjectNamespace(unittest.TestCase):
|
|||||||
self.assertEqual(data["content"], make_content("late-registered"))
|
self.assertEqual(data["content"], make_content("late-registered"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectManagement(unittest.TestCase):
|
||||||
|
"""Todo 3: register / unregister / rename endpoints (failing-first)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self._orig_config = ss.CONFIG
|
||||||
|
ss.CONFIG = ss.Config(Path(self.tmp.name), 0)
|
||||||
|
ss.initialize_storage()
|
||||||
|
self.httpd = HTTPServer(("127.0.0.1", 0), ss.Handler)
|
||||||
|
self.port = self.httpd.server_address[1]
|
||||||
|
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.addCleanup(setattr, ss, "CONFIG", self._orig_config)
|
||||||
|
self.addCleanup(self.httpd.server_close)
|
||||||
|
self.addCleanup(self.httpd.shutdown)
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, json_body=None, raw_body=None):
|
||||||
|
if raw_body is not None:
|
||||||
|
payload = raw_body.encode("utf-8")
|
||||||
|
elif json_body is not None:
|
||||||
|
payload = json.dumps(json_body).encode("utf-8")
|
||||||
|
else:
|
||||||
|
payload = None
|
||||||
|
headers = {"Content-Type": "application/json"} if payload is not None else {}
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
|
conn.request(method, path, body=payload, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
data = resp.read()
|
||||||
|
conn.close()
|
||||||
|
return resp.status, data.decode("utf-8")
|
||||||
|
|
||||||
|
def request_json(self, method: str, path: str, json_body=None, raw_body=None):
|
||||||
|
status, text = self.request(method, path, json_body=json_body, raw_body=raw_body)
|
||||||
|
return status, json.loads(text)
|
||||||
|
|
||||||
|
def registry_on_disk(self) -> dict:
|
||||||
|
with open(ss.CONFIG.registry_file, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def project_names(self) -> list:
|
||||||
|
_, data = self.request_json("GET", "/api/projects")
|
||||||
|
return [p["name"] for p in data["projects"]]
|
||||||
|
|
||||||
|
def register_via_api(self, path, name=None):
|
||||||
|
body = {"path": str(path)} if name is None else {"path": str(path), "name": name}
|
||||||
|
return self.request_json("POST", "/api/projects", json_body=body)
|
||||||
|
|
||||||
|
# (a) register a directory path → backlog.md appended, blank file created, listed
|
||||||
|
def test_register_creates_blank_backlog_and_lists(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "proj"
|
||||||
|
expected_file = proj_dir / "backlog.md"
|
||||||
|
status, resp = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(resp, {"ok": True, "name": "proj", "path": str(expected_file)})
|
||||||
|
|
||||||
|
# File created on disk with the blank template (mirrors startup creation)
|
||||||
|
self.assertTrue(expected_file.is_file())
|
||||||
|
content = expected_file.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("# Backlog", content)
|
||||||
|
self.assertIn("<!-- SECTION: ENTRIES -->", content)
|
||||||
|
self.assertIn("<!-- SECTION: HISTORY -->", content)
|
||||||
|
self.assertIn(ss.BLANK_HISTORY, content)
|
||||||
|
self.assertTrue(content.rstrip().endswith("-->")) # integrity marker present
|
||||||
|
|
||||||
|
# Listed by GET /api/projects and present in the on-disk registry
|
||||||
|
self.assertIn("proj", self.project_names())
|
||||||
|
self.assertEqual(self.registry_on_disk()["proj"],
|
||||||
|
{"path": str(expected_file), "name": "proj"})
|
||||||
|
|
||||||
|
# (b) registering the same derived name again → 409, registry unchanged
|
||||||
|
def test_register_duplicate_name_409(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "proj"
|
||||||
|
status, _ = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
before = self.registry_on_disk()
|
||||||
|
|
||||||
|
status, resp = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 409)
|
||||||
|
self.assertEqual(resp, {"ok": False,
|
||||||
|
"error": "Project 'proj' already registered"})
|
||||||
|
self.assertEqual(self.registry_on_disk(), before)
|
||||||
|
|
||||||
|
# (c) unsafe display name is mangled by safe_project_name
|
||||||
|
def test_register_mangles_unsafe_name(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "p2"
|
||||||
|
status, resp = self.register_via_api(proj_dir, name="My Proj!")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(resp["name"], "My-Proj-")
|
||||||
|
|
||||||
|
# Empty-string name falls back to the directory name
|
||||||
|
status, resp = self.register_via_api(Path(self.tmp.name) / "myproj", name="")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(resp["name"], "myproj")
|
||||||
|
|
||||||
|
# (d) rename: old key gone, content reachable under the new key.
|
||||||
|
# Documented choice: the re-keyed entry is appended at the END of the
|
||||||
|
# registry (insertion position is not preserved) — pinned by the order assert.
|
||||||
|
def test_rename_moves_key_and_keeps_content(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "proj"
|
||||||
|
status, _ = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
content = make_content("renamed-project")
|
||||||
|
status, _ = self.request_json("POST", "/api/projects/proj/backlog",
|
||||||
|
json_body={"content": content})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
status, resp = self.request_json("POST", "/api/projects/proj/rename",
|
||||||
|
json_body={"newName": "beta"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(resp, {"ok": True, "name": "beta"})
|
||||||
|
|
||||||
|
self.assertEqual(self.project_names(), ["default", "beta"]) # append-at-end
|
||||||
|
self.assertNotIn("proj", self.registry_on_disk())
|
||||||
|
status, data = self.request_json("GET", "/api/projects/beta/backlog")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["content"], content)
|
||||||
|
# path preserved through the rename
|
||||||
|
self.assertEqual(self.registry_on_disk()["beta"]["path"], str(proj_dir / "backlog.md"))
|
||||||
|
status, _ = self.request("GET", "/api/projects/proj/backlog")
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
|
||||||
|
# (e1) DELETE without flag → registry entry gone, file (and neighbors) untouched
|
||||||
|
def test_delete_without_flag_keeps_file(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "proj"
|
||||||
|
status, _ = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
master = proj_dir / "backlog.md"
|
||||||
|
backups_dir = proj_dir / "backups"
|
||||||
|
backups_dir.mkdir()
|
||||||
|
(backups_dir / "backlog_manual.md").write_text("keep", encoding="utf-8")
|
||||||
|
(proj_dir / "archive.md").write_text("archive-keep", encoding="utf-8")
|
||||||
|
|
||||||
|
status, resp = self.request_json("DELETE", "/api/projects/proj")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertTrue(resp["ok"])
|
||||||
|
self.assertFalse(resp["deleted"])
|
||||||
|
|
||||||
|
self.assertNotIn("proj", self.registry_on_disk())
|
||||||
|
self.assertEqual(self.project_names(), ["default"])
|
||||||
|
self.assertTrue(master.is_file()) # file still on disk
|
||||||
|
self.assertTrue((backups_dir / "backlog_manual.md").is_file())
|
||||||
|
self.assertTrue((proj_dir / "archive.md").is_file())
|
||||||
|
|
||||||
|
# (e2) DELETE with delete_file=true → the single .md gone; backups/ and archive survive
|
||||||
|
def test_delete_with_flag_removes_only_the_md_file(self):
|
||||||
|
proj_dir = Path(self.tmp.name) / "proj"
|
||||||
|
status, _ = self.register_via_api(proj_dir)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
master = proj_dir / "backlog.md"
|
||||||
|
backups_dir = proj_dir / "backups"
|
||||||
|
backups_dir.mkdir()
|
||||||
|
(backups_dir / "backlog_manual.md").write_text("keep", encoding="utf-8")
|
||||||
|
(proj_dir / "archive.md").write_text("archive-keep", encoding="utf-8")
|
||||||
|
|
||||||
|
status, resp = self.request_json("DELETE", "/api/projects/proj?delete_file=true")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertTrue(resp["ok"])
|
||||||
|
self.assertTrue(resp["deleted"])
|
||||||
|
|
||||||
|
self.assertFalse(master.exists()) # only the .md removed
|
||||||
|
self.assertTrue(backups_dir.is_dir()) # backups dir untouched
|
||||||
|
self.assertTrue((backups_dir / "backlog_manual.md").is_file())
|
||||||
|
self.assertTrue((proj_dir / "archive.md").is_file())
|
||||||
|
self.assertNotIn("proj", self.registry_on_disk())
|
||||||
|
|
||||||
|
# (f) DELETE on the last remaining project → 400, registry unchanged
|
||||||
|
def test_delete_last_project_400(self):
|
||||||
|
status, resp = self.request_json("DELETE", "/api/projects/default")
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(resp, {"error": "At least one project must remain registered"})
|
||||||
|
self.assertIn("default", self.registry_on_disk())
|
||||||
|
self.assertEqual(self.project_names(), ["default"])
|
||||||
|
|
||||||
|
# (g) rename conflicts and unknowns
|
||||||
|
def test_rename_to_existing_name_409(self):
|
||||||
|
for name in ("alpha", "beta"):
|
||||||
|
status, _ = self.register_via_api(Path(self.tmp.name) / name)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
status, resp = self.request_json("POST", "/api/projects/alpha/rename",
|
||||||
|
json_body={"newName": "beta"})
|
||||||
|
self.assertEqual(status, 409)
|
||||||
|
self.assertEqual(resp, {"ok": False, "error": "Project 'beta' already registered"})
|
||||||
|
self.assertIn("alpha", self.registry_on_disk()) # unchanged
|
||||||
|
|
||||||
|
def test_rename_unknown_source_404(self):
|
||||||
|
status, resp = self.request_json("POST", "/api/projects/nope/rename",
|
||||||
|
json_body={"newName": "beta"})
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
self.assertIn("error", resp)
|
||||||
|
|
||||||
|
def test_rename_empty_new_name_400(self):
|
||||||
|
status, _ = self.register_via_api(Path(self.tmp.name) / "proj")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
status, resp = self.request_json("POST", "/api/projects/proj/rename",
|
||||||
|
json_body={"newName": " "})
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(resp, {"ok": False, "error": "Invalid project name"})
|
||||||
|
status, resp = self.request_json("POST", "/api/projects/proj/rename", json_body={})
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertIn("proj", self.registry_on_disk()) # unchanged
|
||||||
|
|
||||||
|
# (g) a name that safe-mangles to empty → 400 (whitespace is truthy, no fallback)
|
||||||
|
def test_register_whitespace_name_400(self):
|
||||||
|
status, resp = self.register_via_api(Path(self.tmp.name) / "proj", name=" ")
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(resp, {"ok": False, "error": "Invalid project name"})
|
||||||
|
self.assertEqual(self.project_names(), ["default"])
|
||||||
|
|
||||||
|
# (h) RELATIVE path resolves against the server process CWD — pinned as intended
|
||||||
|
def test_register_relative_path_resolves_against_cwd(self):
|
||||||
|
cwd_tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(cwd_tmp.cleanup)
|
||||||
|
self.addCleanup(os.chdir, os.getcwd())
|
||||||
|
os.chdir(cwd_tmp.name)
|
||||||
|
status, resp = self.register_via_api("relproj")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
expected = (Path(cwd_tmp.name) / "relproj" / "backlog.md").resolve()
|
||||||
|
self.assertEqual(resp["path"], str(expected))
|
||||||
|
self.assertTrue(expected.is_file())
|
||||||
|
|
||||||
|
# Adversarial: malformed / empty / path-less register bodies → 400, server stays up
|
||||||
|
def test_register_malformed_bodies_400(self):
|
||||||
|
status, resp = self.request_json("POST", "/api/projects",
|
||||||
|
raw_body='{"path": not-json')
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertIn("error", resp)
|
||||||
|
|
||||||
|
status, resp = self.request_json("POST", "/api/projects", json_body={})
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertIn("error", resp)
|
||||||
|
|
||||||
|
status, _ = self.request("POST", "/api/projects") # empty body
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
|
||||||
|
status, _ = self.request("GET", "/api/backlog") # server still alive
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
# Adversarial: DELETE on unknown project / unknown API path / action path → 404
|
||||||
|
def test_delete_unknown_targets_404(self):
|
||||||
|
status, resp = self.request_json("DELETE", "/api/projects/nope")
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
self.assertIn("error", resp)
|
||||||
|
status, _ = self.request("DELETE", "/api/nope")
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
status, _ = self.request("DELETE", "/api/projects/default/backlog")
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user