test_backend_server.py 15.2 KB
"""U-007 tests: backend process start/stop, c_pick_folder route (osascript
mocked at the subprocess boundary), and the zero-third-party-dependency
constraint (05 §5.7) checked statically via AST, not just trusted.

Run: python3 -m unittest src.test_backend_server -v
"""

import ast
import http.client
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch

from src import backend_server


class ServerStartStopTest(unittest.TestCase):
    def test_can_start_serve_and_stop(self):
        # deliberately hits an unrelated route, not /api/system/pick-folder --
        # that one would pop a real, unmocked osascript dialog on this machine
        # and hang waiting for a human who isn't there.
        server = backend_server.create_server(db_path=":memory:")
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            host, port = server.server_address
            conn = http.client.HTTPConnection(host, port, timeout=5)
            conn.request("POST", "/api/does-not-exist", body=b"{}")
            resp = conn.getresponse()
            resp.read()
            conn.close()
            self.assertEqual(resp.status, 404)
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=5)
            self.assertFalse(thread.is_alive())

    def test_unknown_route_returns_404(self):
        server = backend_server.create_server(db_path=":memory:")
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            host, port = server.server_address
            conn = http.client.HTTPConnection(host, port, timeout=5)
            conn.request("POST", "/api/does-not-exist")
            resp = conn.getresponse()
            body = json.loads(resp.read())
            conn.close()
            self.assertEqual(resp.status, 404)
            self.assertEqual(body["error"], "NOT_FOUND")
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=5)


class StaticFileServingTest(unittest.TestCase):
    """U-008: frontend shell served as static files. Actual client-side
    routing behavior was verified in a real browser this session (nav
    highlighting + content swap across all 3 routes, no page reload) --
    that's not something a stdlib-only test can exercise without a JS
    engine, so this only covers what the server is responsible for."""

    def setUp(self):
        self.server = backend_server.create_server(db_path=":memory:")
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()

    def tearDown(self):
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(timeout=5)

    def _get(self, path):
        host, port = self.server.server_address
        conn = http.client.HTTPConnection(host, port, timeout=5)
        conn.request("GET", path)
        resp = conn.getresponse()
        body = resp.read()
        content_type = resp.getheader("Content-Type")
        conn.close()
        return resp.status, content_type, body

    def test_root_serves_index_html(self):
        status, content_type, body = self._get("/")
        self.assertEqual(status, 200)
        self.assertIn("text/html", content_type)
        self.assertIn(b"<main id=\"app\">", body)
        self.assertIn(b'href="#/tasks"', body)

    def test_app_js_served_with_js_content_type(self):
        status, content_type, body = self._get("/app.js")
        self.assertEqual(status, 200)
        self.assertIn("javascript", content_type)
        self.assertIn(b"hashchange", body)
        self.assertIn(b'"#/tasks"', body)
        self.assertIn(b'"#/settings"', body)

    def test_style_css_served_with_css_content_type(self):
        status, content_type, body = self._get("/style.css")
        self.assertEqual(status, 200)
        self.assertIn("text/css", content_type)

    def test_unknown_get_path_returns_404(self):
        status, _, body = self._get("/nope")
        self.assertEqual(status, 404)
        self.assertEqual(json.loads(body)["error"], "NOT_FOUND")


class PickFolderTest(unittest.TestCase):
    def test_success_returns_selected_path(self):
        with patch("src.backend_server.platform.system", return_value="Darwin"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["osascript"], returncode=0, stdout="/Users/x/some-project\n", stderr=""
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": "/Users/x/some-project"})

    def test_user_cancel_returns_null_path_not_an_error(self):
        with patch("src.backend_server.platform.system", return_value="Darwin"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["osascript"], returncode=1, stdout="",
                stderr="execution error: User canceled. (-128)",
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": None})

    def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self):
        with patch("src.backend_server.platform.system", return_value="Darwin"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["osascript"], returncode=1, stdout="", stderr="some other AppleScript error",
            )
            with self.assertRaises(backend_server.DialogUnavailableError):
                backend_server.pick_folder()

    def test_windows_success_returns_selected_path(self):
        with patch("src.backend_server.platform.system", return_value="Windows"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["powershell"], returncode=0, stdout="C:\\Users\\x\\some-project\r\n", stderr=""
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": "C:\\Users\\x\\some-project"})

    def test_windows_user_cancel_returns_null_path(self):
        with patch("src.backend_server.platform.system", return_value="Windows"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["powershell"], returncode=0, stdout="", stderr=""
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": None})

    def test_windows_missing_powershell_raises_dialog_unavailable(self):
        with patch("src.backend_server.platform.system", return_value="Windows"), \
             patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("no powershell")):
            with self.assertRaises(backend_server.DialogUnavailableError):
                backend_server.pick_folder()

    def test_linux_zenity_success_returns_selected_path(self):
        with patch("src.backend_server.platform.system", return_value="Linux"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["zenity"], returncode=0, stdout="/home/x/some-project\n", stderr=""
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": "/home/x/some-project"})
        run.assert_called_once()
        self.assertEqual(run.call_args[0][0][0], "zenity")

    def test_linux_zenity_cancel_returns_null_path(self):
        with patch("src.backend_server.platform.system", return_value="Linux"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["zenity"], returncode=1, stdout="", stderr=""
            )
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": None})

    def test_linux_falls_back_to_kdialog_when_zenity_missing(self):
        with patch("src.backend_server.platform.system", return_value="Linux"), \
             patch("src.backend_server.subprocess.run") as run:
            run.side_effect = [
                FileNotFoundError("no zenity"),
                subprocess.CompletedProcess(args=["kdialog"], returncode=0, stdout="/home/x/proj\n", stderr=""),
            ]
            result = backend_server.pick_folder()
        self.assertEqual(result, {"selected_path": "/home/x/proj"})
        self.assertEqual(run.call_count, 2)

    def test_linux_neither_zenity_nor_kdialog_raises_dialog_unavailable(self):
        with patch("src.backend_server.platform.system", return_value="Linux"), \
             patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")):
            with self.assertRaises(backend_server.DialogUnavailableError):
                backend_server.pick_folder()


class PickFolderRouteTest(unittest.TestCase):
    def setUp(self):
        self.server = backend_server.create_server(db_path=":memory:")
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()

    def tearDown(self):
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(timeout=5)

    def _post(self, path):
        host, port = self.server.server_address
        conn = http.client.HTTPConnection(host, port, timeout=5)
        conn.request("POST", path, body=b"{}")
        resp = conn.getresponse()
        body = json.loads(resp.read())
        conn.close()
        return resp.status, body

    def test_route_returns_selected_path_on_success(self):
        with patch("src.backend_server.platform.system", return_value="Darwin"), \
             patch("src.backend_server.subprocess.run") as run:
            run.return_value = subprocess.CompletedProcess(
                args=["osascript"], returncode=0, stdout="/tmp/chosen\n", stderr=""
            )
            status, body = self._post("/api/system/pick-folder")
        self.assertEqual(status, 200)
        self.assertEqual(body, {"selected_path": "/tmp/chosen"})

    def test_route_returns_422_dialog_unavailable_when_no_dialog_tool_present(self):
        with patch("src.backend_server.platform.system", return_value="Linux"), \
             patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")):
            status, body = self._post("/api/system/pick-folder")
        self.assertEqual(status, 422)
        self.assertEqual(body["error"], "DIALOG_UNAVAILABLE")


class ProjectCreateRouteTest(unittest.TestCase):
    """c_project_create (03-C §3.0) wired to a real HTTP route for the first
    time in U-022 -- project_scaffold.create_project() itself was already
    fully implemented and tested in U-002, so this only exercises the
    request/response/error-code plumbing, not the scaffolding logic again."""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
        self.server = backend_server.create_server(db_path=":memory:")
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()

    def tearDown(self):
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(timeout=5)

    def _post(self, path, payload):
        host, port = self.server.server_address
        conn = http.client.HTTPConnection(host, port, timeout=5)
        conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
        resp = conn.getresponse()
        body = json.loads(resp.read())
        conn.close()
        return resp.status, body

    def test_creates_project_for_existing_git_repo(self):
        status, body = self._post("/api/projects", {"target_folder_path": self.tmp.name})
        self.assertEqual(status, 200)
        self.assertTrue(body["has_git_repo"])
        self.assertTrue(body["graphlemon_subdir_initialized"])
        self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, ".graphlemon")))

    def test_repeat_call_is_idempotent(self):
        status1, body1 = self._post("/api/projects", {"target_folder_path": self.tmp.name})
        status2, body2 = self._post("/api/projects", {"target_folder_path": self.tmp.name})
        self.assertEqual(status1, 200)
        self.assertEqual(status2, 200)
        self.assertEqual(body1["project_id"], body2["project_id"])

    def test_missing_target_folder_path_returns_400(self):
        status, body = self._post("/api/projects", {})
        self.assertEqual(status, 400)
        self.assertEqual(body["error"], "INVALID_INPUT")

    def test_nonexistent_path_returns_404(self):
        status, body = self._post("/api/projects", {"target_folder_path": "/no/such/path/at/all"})
        self.assertEqual(status, 404)
        self.assertEqual(body["error"], "PATH_NOT_FOUND")

    def test_non_git_folder_without_confirmation_returns_409(self):
        non_git = tempfile.mkdtemp()
        self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True))
        status, body = self._post("/api/projects", {"target_folder_path": non_git})
        self.assertEqual(status, 409)
        self.assertEqual(body["error"], "GIT_INIT_DECLINED")

    def test_non_git_folder_with_confirmation_initializes_git(self):
        non_git = tempfile.mkdtemp()
        self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True))
        status, body = self._post(
            "/api/projects", {"target_folder_path": non_git, "init_git_if_missing": True}
        )
        self.assertEqual(status, 200)
        self.assertTrue(body["has_git_repo"])
        self.assertTrue(os.path.isdir(os.path.join(non_git, ".git")))


class ZeroThirdPartyDependencyTest(unittest.TestCase):
    """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
    doesn't silently rot if someone adds an import later."""

    def test_backend_server_only_imports_stdlib(self):
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backend_server.py")
        with open(path, encoding="utf-8") as f:
            tree = ast.parse(f.read(), filename=path)

        top_level_names = set()
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    top_level_names.add(alias.name.split(".")[0])
            elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
                top_level_names.add(node.module.split(".")[0])

        self.assertTrue(top_level_names, "expected at least one import to check")
        # "src" is graphlemon's own package (e.g. project_scaffold/schema/paths,
        # wired in U-022) -- not a third-party dependency, so it's allowed;
        # everything else must be in the standard library.
        non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names and name != "src"}
        self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib))


if __name__ == "__main__":
    unittest.main()