""" Unit tests for installer/core/monitor.py Covers the pure-logic helpers: _parse_pf_cfg, write_port_forwards_cfg, check_port_conflicts, _build_kubectl_cmd, _start_port_forwards """ from __future__ import annotations import subprocess from pathlib import Path from unittest.mock import MagicMock, patch, call from knoe.core.monitor import ( _parse_pf_cfg, write_port_forwards_cfg, check_port_conflicts, _build_kubectl_cmd, _start_port_forwards, ) # --------------------------------------------------------------------------- # _parse_pf_cfg # --------------------------------------------------------------------------- def test_parse_pf_cfg_missing_file(tmp_path): result = _parse_pf_cfg(tmp_path / "nonexistent.cfg") assert result == [] def test_parse_pf_cfg_empty_file(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text("") assert _parse_pf_cfg(cfg) == [] def test_parse_pf_cfg_comments_and_blanks(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text("# this is a comment\n\n \n") assert _parse_pf_cfg(cfg) == [] def test_parse_pf_cfg_no_colon(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text("some line without colon\n") assert _parse_pf_cfg(cfg) == [] def test_parse_pf_cfg_single_entry(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text("argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1\n") result = _parse_pf_cfg(cfg) assert len(result) == 1 m = result[0] assert m["id"] == "argocd" assert m["local"] == "8080" assert m["remote"] == "80" assert m["ns"] == "argocd" assert m["svc"] == "argocd-server" assert m["address"] == "127.0.0.1" def test_parse_pf_cfg_multiple_entries(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text( "# Port forwards\n" "argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1\n" "openbao: local=8200 remote=8200 ns=default svc=openbao address=127.0.0.1\n" ) result = _parse_pf_cfg(cfg) assert len(result) == 2 assert result[0]["id"] == "argocd" assert result[1]["id"] == "openbao" assert result[1]["local"] == "8200" def test_parse_pf_cfg_token_without_equals(tmp_path): """Tokens without '=' should be silently ignored.""" cfg = tmp_path / "pf.cfg" cfg.write_text("myid: local=9000 badtoken remote=80\n") result = _parse_pf_cfg(cfg) assert len(result) == 1 assert result[0]["local"] == "9000" assert result[0]["remote"] == "80" # 'badtoken' has no '=' so it's not added assert "badtoken" not in result[0] def test_parse_pf_cfg_id_with_extra_whitespace(tmp_path): cfg = tmp_path / "pf.cfg" cfg.write_text(" myservice : local=1234 remote=5678\n") result = _parse_pf_cfg(cfg) assert len(result) == 1 assert result[0]["id"] == "myservice" # --------------------------------------------------------------------------- # write_port_forwards_cfg # --------------------------------------------------------------------------- def test_write_port_forwards_cfg_empty(tmp_path): out = tmp_path / "out.cfg" write_port_forwards_cfg(out, []) content = out.read_text() assert "# Port forward configuration" in content assert "# Format:" in content def test_write_port_forwards_cfg_skips_no_id(tmp_path): out = tmp_path / "out.cfg" # Mapping string with no id= token write_port_forwards_cfg(out, ["hostPort=8080;servicePort=80"]) content = out.read_text() # No entry line should be written (id is empty) lines = [l for l in content.splitlines() if l and not l.startswith("#")] assert lines == [] def test_write_port_forwards_cfg_single_mapping(tmp_path): out = tmp_path / "out.cfg" mapping = "id=argocd;namespace=argocd;target=svc/argocd-server;hostPort=8080;servicePort=80;address=127.0.0.1" write_port_forwards_cfg(out, [mapping]) content = out.read_text() assert "argocd: local=8080 remote=80 ns=argocd svc=argocd-server address=127.0.0.1" in content def test_write_port_forwards_cfg_target_without_svc_prefix(tmp_path): out = tmp_path / "out.cfg" mapping = "id=myapp;namespace=default;target=myapp-service;hostPort=9000;servicePort=9000;address=0.0.0.0" write_port_forwards_cfg(out, [mapping]) content = out.read_text() # target doesn't start with svc/, so svc = target as-is assert "svc=myapp-service" in content def test_write_port_forwards_cfg_roundtrip(tmp_path): """Written file can be parsed back by _parse_pf_cfg.""" out = tmp_path / "pf.cfg" mappings = [ "id=argocd;namespace=argocd;target=svc/argocd-server;hostPort=8080;servicePort=80;address=127.0.0.1", "id=openbao;namespace=default;target=svc/openbao;hostPort=8200;servicePort=8200;address=127.0.0.1", ] write_port_forwards_cfg(out, mappings) parsed = _parse_pf_cfg(out) assert len(parsed) == 2 assert parsed[0]["id"] == "argocd" assert parsed[1]["id"] == "openbao" assert parsed[0]["local"] == "8080" assert parsed[1]["remote"] == "8200" # --------------------------------------------------------------------------- # check_port_conflicts # --------------------------------------------------------------------------- def test_check_port_conflicts_empty(): assert check_port_conflicts([]) == [] def test_check_port_conflicts_no_conflicts(): mappings = [ {"id": "a", "local": "8080"}, {"id": "b", "local": "8200"}, {"id": "c", "local": "9000"}, ] assert check_port_conflicts(mappings) == [] def test_check_port_conflicts_single_conflict(): mappings = [ {"id": "a", "local": "8080"}, {"id": "b", "local": "8080"}, ] result = check_port_conflicts(mappings) assert len(result) == 1 assert "8080" in result[0] assert "a" in result[0] assert "b" in result[0] def test_check_port_conflicts_multiple_conflicts(): mappings = [ {"id": "a", "local": "8080"}, {"id": "b", "local": "8080"}, {"id": "c", "local": "9000"}, {"id": "d", "local": "9000"}, ] result = check_port_conflicts(mappings) assert len(result) == 2 def test_check_port_conflicts_missing_local_key(): """Mappings without 'local' key use '' as port and should not crash.""" mappings = [ {"id": "a"}, {"id": "b"}, ] result = check_port_conflicts(mappings) # Both have empty port '' — should detect conflict assert len(result) == 1 def test_check_port_conflicts_missing_id_key(): """Mappings without 'id' key should not crash.""" mappings = [ {"local": "8080"}, {"local": "8080"}, ] result = check_port_conflicts(mappings) assert len(result) == 1 assert "?" in result[0] # --------------------------------------------------------------------------- # _build_kubectl_cmd # --------------------------------------------------------------------------- def test_build_kubectl_cmd_basic(): m = {"ns": "argocd", "svc": "argocd-server", "local": "8080", "remote": "80", "address": "127.0.0.1"} cmd = _build_kubectl_cmd(m) assert cmd == ["kubectl", "port-forward", "-n", "argocd", "svc/argocd-server", "8080:80"] # address is 127.0.0.1 so --address is NOT added assert "--address" not in cmd def test_build_kubectl_cmd_non_localhost_address(): m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": "0.0.0.0"} cmd = _build_kubectl_cmd(m) assert "--address" in cmd assert "0.0.0.0" in cmd def test_build_kubectl_cmd_svc_prefix_not_doubled(): """svc/ prefix should only appear once.""" m = {"ns": "default", "svc": "svc/myapp", "local": "9000", "remote": "9000", "address": "127.0.0.1"} cmd = _build_kubectl_cmd(m) svc_arg = cmd[4] assert svc_arg == "svc/myapp" assert not svc_arg.startswith("svc/svc/") def test_build_kubectl_cmd_adds_svc_prefix(): """Service name without svc/ prefix should get one.""" m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": "127.0.0.1"} cmd = _build_kubectl_cmd(m) assert "svc/myapp" in cmd def test_build_kubectl_cmd_defaults(): """Missing keys should fall back to defaults.""" cmd = _build_kubectl_cmd({}) assert cmd[0] == "kubectl" assert "-n" in cmd assert "default" in cmd # default namespace def test_build_kubectl_cmd_empty_address(): """Empty address string should not add --address flag.""" m = {"ns": "default", "svc": "myapp", "local": "9000", "remote": "9000", "address": ""} cmd = _build_kubectl_cmd(m) assert "--address" not in cmd # --------------------------------------------------------------------------- # _start_port_forwards # --------------------------------------------------------------------------- def test_start_port_forwards_empty(): result = _start_port_forwards([]) assert result == [] def test_start_port_forwards_success(capsys): mapping = {"id": "argocd", "ns": "argocd", "svc": "argocd-server", "local": "8080", "remote": "80", "address": "127.0.0.1"} mock_proc = MagicMock(spec=subprocess.Popen) with patch("knoe.core.monitor.subprocess.Popen", return_value=mock_proc) as mock_popen: result = _start_port_forwards([mapping]) assert len(result) == 1 m_out, proc_out = result[0] assert m_out is mapping assert proc_out is mock_proc out = capsys.readouterr().out assert "argocd" in out assert "✓" in out def test_start_port_forwards_failure(capsys): mapping = {"id": "broken", "ns": "default", "svc": "nosvc", "local": "9999", "remote": "9999", "address": "127.0.0.1"} with patch("knoe.core.monitor.subprocess.Popen", side_effect=OSError("not found")): result = _start_port_forwards([mapping]) assert len(result) == 1 _, proc = result[0] assert proc is None out = capsys.readouterr().out assert "✗" in out assert "broken" in out def test_start_port_forwards_verbose(capsys): mapping = {"id": "test", "ns": "default", "svc": "myapp", "local": "8000", "remote": "8000", "address": "127.0.0.1"} mock_proc = MagicMock(spec=subprocess.Popen) with patch("knoe.core.monitor.subprocess.Popen", return_value=mock_proc): _start_port_forwards([mapping], verbose=True) out = capsys.readouterr().out assert "[PORT-FWD]" in out def test_start_port_forwards_multiple(capsys): mappings = [ {"id": "a", "ns": "default", "svc": "svc-a", "local": "8001", "remote": "80", "address": "127.0.0.1"}, {"id": "b", "ns": "default", "svc": "svc-b", "local": "8002", "remote": "80", "address": "127.0.0.1"}, ] mock_proc = MagicMock(spec=subprocess.Popen) with patch("knoe.core.monitor.subprocess.Popen", return_value=mock_proc): result = _start_port_forwards(mappings) assert len(result) == 2 assert result[0][0]["id"] == "a" assert result[1][0]["id"] == "b"