diff --git a/server/recceiver/processors.py b/server/recceiver/processors.py index 352f67df..f984237d 100644 --- a/server/recceiver/processors.py +++ b/server/recceiver/processors.py @@ -50,6 +50,12 @@ def getboolean(self, key, D=None): except (ConfigParser.NoOptionError, ValueError): return D + def getint(self, key, D=None): + try: + return self._C.getint(self._S, key, vars=self.env_vars) + except (ConfigParser.NoOptionError, ValueError): + return D + def __getitem__(self, key): result = self.get(key) if result is None: diff --git a/server/sonar-project.properties b/server/sonar-project.properties new file mode 100644 index 00000000..f1bc3af5 --- /dev/null +++ b/server/sonar-project.properties @@ -0,0 +1 @@ +sonar.python.version=3.9 diff --git a/server/tests/test_cfstore.py b/server/tests/test_cfstore.py new file mode 100644 index 00000000..da909ee7 --- /dev/null +++ b/server/tests/test_cfstore.py @@ -0,0 +1,52 @@ +from configparser import ConfigParser + +from recceiver.cfstore import CFConfig +from recceiver.processors import ConfigAdapter + + +def make_adapter(section: str = "cf", values: dict = None, env: dict = None) -> ConfigAdapter: + parser = ConfigParser() + parser.add_section(section) + for key, value in (values or {}).items(): + parser.set(section, key, str(value)) + adapter = ConfigAdapter(parser, section) + if env: + adapter.env_vars = env + return adapter + + +class TestCFConfigLoads: + def test_loads_defaults_without_error(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert isinstance(config, CFConfig) + + def test_default_push_max_retries(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_max_retries == 10 + + def test_push_max_retries_from_config(self): + adapter = make_adapter(values={"pushmaxretries": "3"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 3 + + def test_push_max_retries_from_env(self): + adapter = make_adapter(env={"pushmaxretries": "7"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 7 + + def test_default_push_always_retry(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_always_retry is True + + def test_alias_disabled_by_default(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.alias_enabled is False + + def test_alias_enabled_from_config(self): + adapter = make_adapter(values={"alias": "true"}) + config = CFConfig.loads(adapter) + assert config.alias_enabled is True diff --git a/server/tests/test_container_smoke.py b/server/tests/test_container_smoke.py new file mode 100644 index 00000000..595241fc --- /dev/null +++ b/server/tests/test_container_smoke.py @@ -0,0 +1,46 @@ +import time +from pathlib import Path + +import pytest + +from docker import DockerClient + +SERVER_DIR = Path(__file__).parent.parent + + +@pytest.fixture(scope="session") +def recceiver_image(): + client = DockerClient() + image, _ = client.images.build(path=str(SERVER_DIR), tag="recceiver-test:smoke", rm=True) + yield image.id + client.images.remove(image.id, force=True) + + +class TestContainerSmoke: + def test_container_starts_and_parses_config(self, recceiver_image): + """Verify the container reaches the CF connection attempt rather than crashing during config parsing. + + A config parsing error (e.g. AttributeError on a missing ConfigAdapter method) causes the + container to exit before CF_START is logged. A healthy startup logs CF_START first and only + then fails to connect to CF. + """ + client = DockerClient() + container = client.containers.run( + recceiver_image, + environment={ + "RECCEIVER_RECCEIVER_PROCS": "cf", + "RECCEIVER_RECCEIVER_LOGLEVEL": "INFO", + "RECCEIVER_CF_BASEURL": "https://nonexistent-cf:8080/ChannelFinder", + "RECCEIVER_CF_CFUSERNAME": "admin", + "RECCEIVER_CF_CFPASSWORD": "password", + }, + detach=True, + ) + try: + time.sleep(10) + logs = container.logs().decode() + assert "AttributeError" not in logs, f"Container crashed with AttributeError:\n{logs}" + assert "CF_START" in logs, f"CF_START not found in logs — service may not have started:\n{logs}" + finally: + container.stop(timeout=5) + container.remove() diff --git a/server/tests/test_processors.py b/server/tests/test_processors.py new file mode 100644 index 00000000..16bc2a64 --- /dev/null +++ b/server/tests/test_processors.py @@ -0,0 +1,99 @@ +import textwrap +from configparser import ConfigParser +from pathlib import Path + +from recceiver.cfstore import CFProcessor +from recceiver.processors import ConfigAdapter, ProcessorController + + +def make_adapter(section: str = "cf", values: dict = None, env: dict = None) -> ConfigAdapter: + parser = ConfigParser() + parser.add_section(section) + for key, value in (values or {}).items(): + parser.set(section, key, str(value)) + adapter = ConfigAdapter(parser, section) + if env: + adapter.env_vars = env + return adapter + + +class TestConfigAdapterGet: + def test_returns_value(self): + adapter = make_adapter(values={"baseurl": "https://cf:8080"}) + assert adapter.get("baseurl") == "https://cf:8080" + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.get("baseurl", "fallback") == "fallback" + + def test_returns_none_when_missing_and_no_default(self): + adapter = make_adapter() + assert adapter.get("baseurl") is None + + def test_env_var_overrides_config(self): + adapter = make_adapter(values={"baseurl": "https://original"}, env={"baseurl": "https://override"}) + assert adapter.get("baseurl") == "https://override" + + def test_env_var_provides_value_not_in_config(self): + adapter = make_adapter(env={"baseurl": "https://from-env"}) + assert adapter.get("baseurl") == "https://from-env" + + +class TestConfigAdapterGetBoolean: + def test_returns_true(self): + adapter = make_adapter(values={"alias": "true"}) + assert adapter.getboolean("alias") is True + + def test_returns_false(self): + adapter = make_adapter(values={"alias": "false"}) + assert adapter.getboolean("alias") is False + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.getboolean("alias", False) is False + + def test_returns_default_on_invalid_value(self): + adapter = make_adapter(values={"alias": "notabool"}) + assert adapter.getboolean("alias", True) is True + + +class TestConfigAdapterGetInt: + def test_returns_int(self): + adapter = make_adapter(values={"pushmaxretries": "5"}) + assert adapter.getint("pushmaxretries") == 5 + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.getint("pushmaxretries", 10) == 10 + + def test_returns_none_when_missing_and_no_default(self): + adapter = make_adapter() + assert adapter.getint("pushmaxretries") is None + + def test_returns_default_on_invalid_value(self): + adapter = make_adapter(values={"pushmaxretries": "notanint"}) + assert adapter.getint("pushmaxretries", 10) == 10 + + def test_env_var_overrides_config(self): + adapter = make_adapter(values={"pushmaxretries": "5"}, env={"pushmaxretries": "99"}) + assert adapter.getint("pushmaxretries", 10) == 99 + + +class TestProcessorControllerFromConfig: + def test_cf_processor_initialized_from_config_file(self, tmp_path: Path): + config_file = tmp_path / "recceiver.conf" + config_file.write_text( + textwrap.dedent( + """\ + [recceiver] + procs = cf + + [cf] + pushMaxRetries = 5 + """ + ) + ) + ctrl = ProcessorController(cfile=str(config_file)) + assert len(ctrl.procs) == 1 + assert isinstance(ctrl.procs[0], CFProcessor) + assert ctrl.procs[0].cf_config.push_max_retries == 5