test_backend_server.py 8.5 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 subprocess
import sys
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()
        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()
        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()
        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_non_macos_raises_dialog_unavailable(self):
        with patch("src.backend_server.platform.system", return_value="Linux"):
            with self.assertRaises(backend_server.DialogUnavailableError):
                backend_server.pick_folder()

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


class PickFolderRouteTest(unittest.TestCase):
    def setUp(self):
        self.server = backend_server.create_server()
        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_off_macos(self):
        with patch("src.backend_server.platform.system", return_value="Linux"):
            status, body = self._post("/api/system/pick-folder")
        self.assertEqual(status, 422)
        self.assertEqual(body["error"], "DIALOG_UNAVAILABLE")


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")
        non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names}
        self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib))


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