from __future__ import annotations import json from pathlib import Path from types import SimpleNamespace import pytest from codexpi.docker_service import DockerError, _belongs_to_project, container_action, containers def inspected(root: Path, *, name: str = "project-web", working_dir: str | None = None, running: bool = False) -> dict: return { "Id": "a" * 64, "Name": f"/{name}", "Config": {"Labels": { "com.docker.compose.project": "example", "com.docker.compose.service": "web", "com.docker.compose.project.working_dir": working_dir or str(root), "com.docker.compose.project.config_files": str(root / "docker-compose.yml"), }}, "Mounts": [], "State": {"Running": running}, } def test_project_membership_uses_compose_directory_or_bind_mount(tmp_path: Path): project = tmp_path / "project" project.mkdir() assert _belongs_to_project(inspected(project), project) assert not _belongs_to_project(inspected(tmp_path / "other"), project) assert _belongs_to_project({"Config": {"Labels": {}}, "Mounts": [ {"Type": "bind", "Source": str(project / "src")}, ]}, project) @pytest.mark.asyncio async def test_container_listing_only_returns_current_project(tmp_path: Path, monkeypatch): project = tmp_path / "project" project.mkdir() rows = [ {"ID": "a" * 12, "Names": "project-web"}, {"ID": "b" * 12, "Names": "someone-else"}, ] values = [inspected(project), {**inspected(tmp_path / "other", name="someone-else"), "Id": "b" * 64}] async def fake(argv, cwd=None, timeout=60): stdout = "\n".join(json.dumps(row) for row in rows) if argv[1] == "ps" else json.dumps(values) return SimpleNamespace(returncode=0, stdout=stdout, stderr="") monkeypatch.setattr("codexpi.docker_service.run_command", fake) result = await containers(project) assert [json.loads(line)["Names"] for line in result["stdout"].splitlines()] == ["project-web"] @pytest.mark.asyncio async def test_rebuild_targets_only_the_scoped_compose_service(tmp_path: Path, monkeypatch): project = tmp_path / "project" project.mkdir() calls: list[tuple[list[str], Path | None]] = [] async def fake(argv, cwd=None, timeout=60): calls.append((argv, cwd)) if len(calls) == 1: return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="") if len(calls) == 2: return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project)]), stderr="") return SimpleNamespace(returncode=0, stdout="rebuilt", stderr="") monkeypatch.setattr("codexpi.docker_service.run_command", fake) monkeypatch.setattr("codexpi.docker_service._compose_prefix", lambda _root: ["docker", "compose"]) result = await container_action("project-web", "rebuild", project) assert result["stdout"] == "rebuilt" assert calls[-1] == ([ "docker", "compose", "-f", str(project / "docker-compose.yml"), "--project-name", "example", "up", "-d", "--build", "web", ], project) @pytest.mark.asyncio async def test_project_action_rejects_unrelated_container(tmp_path: Path, monkeypatch): project = tmp_path / "project" project.mkdir() async def fake(argv, cwd=None, timeout=60): if argv[1] == "ps": return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "b" * 12, "Names": "other"}), stderr="") return SimpleNamespace(returncode=0, stdout=json.dumps([{**inspected(tmp_path / "other", name="other"), "Id": "b" * 64}]), stderr="") monkeypatch.setattr("codexpi.docker_service.run_command", fake) with pytest.raises(DockerError, match="n’appartient pas"): await container_action("other", "restart", project) @pytest.mark.asyncio async def test_delete_only_removes_stopped_scoped_container(tmp_path: Path, monkeypatch): project = tmp_path / "project" project.mkdir() calls: list[list[str]] = [] async def fake(argv, cwd=None, timeout=60): calls.append(argv) if argv[1] == "ps": return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="") if argv[1] == "inspect": return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project)]), stderr="") return SimpleNamespace(returncode=0, stdout="project-web", stderr="") monkeypatch.setattr("codexpi.docker_service.run_command", fake) result = await container_action("project-web", "delete", project) assert result["stdout"] == "project-web" assert calls[-1] == ["docker", "rm", "project-web"] @pytest.mark.asyncio async def test_delete_refuses_running_container(tmp_path: Path, monkeypatch): project = tmp_path / "project" project.mkdir() async def fake(argv, cwd=None, timeout=60): if argv[1] == "ps": return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="") return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project, running=True)]), stderr="") monkeypatch.setattr("codexpi.docker_service.run_command", fake) with pytest.raises(DockerError, match="ArrĂȘtez"): await container_action("project-web", "delete", project)