From f51fbe76b42406b1a0874761fb928329d18dd154 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 24 Apr 2026 18:35:00 +0000 Subject: [PATCH 1/4] Update docker/login-action action to v4 --- .github/workflows/build-and-push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 4e7843dd..4c31e8c9 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -32,13 +32,13 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.repository_owner }} From c997750d621fdb8009c852f854b69013ba0e60bc Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 24 Apr 2026 18:35:08 +0000 Subject: [PATCH 2/4] Update docker/setup-buildx-action action to v4 --- .github/workflows/build-and-push.yml | 2 +- .github/workflows/full-test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 4c31e8c9..9074db3a 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to DockerHub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 diff --git a/.github/workflows/full-test.yml b/.github/workflows/full-test.yml index f183efda..2b72424b 100644 --- a/.github/workflows/full-test.yml +++ b/.github/workflows/full-test.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Read Photon version from .last_release id: photon_version From 4910e0b180ad60b7c50eec072a16f77917045ed3 Mon Sep 17 00:00:00 2001 From: Robin Tuszik Date: Tue, 28 Apr 2026 00:49:57 +0200 Subject: [PATCH 3/4] feat: multi-stage build without uv runtime dependency --- Dockerfile | 54 +++++++++++++++++++++++++++++------------- src/process_manager.py | 4 ++-- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index c15b088e..1c1578b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,40 @@ -FROM eclipse-temurin:21.0.9_10-jre-noble +FROM ubuntu:noble AS builder + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get -y install --no-install-recommends \ + python3.12 \ + && rm -rf /var/lib/apt/lists/* -# install astral uv COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/ +WORKDIR /build + +COPY pyproject.toml uv.lock ./ + +ENV UV_PYTHON=/usr/bin/python3.12 \ + UV_PYTHON_PREFERENCE=only-system \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/photon/.venv + +RUN uv sync --locked --no-dev --no-install-project + + +FROM eclipse-temurin:21.0.9_10-jre-noble + ARG DEBIAN_FRONTEND=noninteractive ARG PHOTON_VERSION ARG PUID=9011 ARG PGID=9011 RUN apt-get update \ - && apt-get -y install --no-install-recommends \ - lbzip2 \ - gosu \ - python3.12 \ - curl \ - && rm -rf /var/lib/apt/lists/* + && apt-get -y install --no-install-recommends \ + lbzip2 \ + gosu \ + python3.12 \ + curl \ + && rm -rf /var/lib/apt/lists/* RUN groupadd -g ${PGID} -o photon && \ useradd -l -u ${PUID} -g photon -o -s /bin/false -m -d /photon photon @@ -27,24 +47,24 @@ ADD https://github.com/komoot/photon/releases/download/${PHOTON_VERSION}/photon- COPY src/ ./src/ COPY entrypoint.sh . -COPY pyproject.toml . -COPY uv.lock . -RUN gosu photon uv sync --locked +COPY --from=builder /photon/.venv /photon/.venv +ENV PATH="/photon/.venv/bin:${PATH}" \ + VIRTUAL_ENV=/photon/.venv RUN chmod 644 /photon/photon.jar && \ chown -R photon:photon /photon LABEL org.opencontainers.image.title="photon-docker" \ - org.opencontainers.image.description="Unofficial docker image for the Photon Geocoder" \ - org.opencontainers.image.url="https://github.com/rtuszik/photon-docker" \ - org.opencontainers.image.source="https://github.com/rtuszik/photon-docker" \ - org.opencontainers.image.documentation="https://github.com/rtuszik/photon-docker#readme" + org.opencontainers.image.description="Unofficial docker image for the Photon Geocoder" \ + org.opencontainers.image.url="https://github.com/rtuszik/photon-docker" \ + org.opencontainers.image.source="https://github.com/rtuszik/photon-docker" \ + org.opencontainers.image.documentation="https://github.com/rtuszik/photon-docker#readme" EXPOSE 2322 HEALTHCHECK --interval=30s --timeout=10s --start-period=240s --retries=3 \ - CMD curl -f http://localhost:2322/status || exit 1 + CMD curl -f http://localhost:2322/status || exit 1 ENTRYPOINT ["/bin/sh", "entrypoint.sh"] -CMD ["uv", "run", "-m", "src.process_manager"] +CMD ["/photon/.venv/bin/python", "-m", "src.process_manager"] diff --git a/src/process_manager.py b/src/process_manager.py index 392beb06..f9204182 100644 --- a/src/process_manager.py +++ b/src/process_manager.py @@ -79,7 +79,7 @@ def handle_shutdown(self, signum, _frame): def run_initial_setup(self): logger.info("Running initial setup...") - result = subprocess.run(["uv", "run", "--no-sync", "-m", "src.entrypoint", "setup"], check=False, cwd="/photon") # noqa S603 + result = subprocess.run([sys.executable, "-m", "src.entrypoint", "setup"], check=False, cwd="/photon") # noqa S603 if result.returncode != 0: logger.error("Setup failed!") @@ -218,7 +218,7 @@ def run_update(self): if config.UPDATE_STRATEGY == "SEQUENTIAL": self.stop_photon() - result = subprocess.run(["uv", "run", "--no-sync", "-m", "src.updater"], check=False, cwd="/photon") # noqa S603 + result = subprocess.run([sys.executable, "-m", "src.updater"], check=False, cwd="/photon") # noqa S603 if result.returncode == 0: logger.info("Update process completed, verifying Photon health...") From 4007223ba017cd9b38ff2c57e14acd94196792b1 Mon Sep 17 00:00:00 2001 From: Robin Tuszik Date: Sun, 10 May 2026 13:17:07 +0200 Subject: [PATCH 4/4] increase test coverage --- pyproject.toml | 3 + tests/test_check_remote.py | 209 ++++++++++++++ tests/test_downloader.py | 493 ++++++++++++++++++++++++++++++++++ tests/test_entrypoint.py | 208 ++++++++++++++ tests/test_filesystem.py | 299 +++++++++++++++++++++ tests/test_process_manager.py | 441 ++++++++++++++++++++++++++++++ tests/test_updater.py | 57 ++++ tests/utils/test_logger.py | 71 +++++ tests/utils/test_notify.py | 65 +++++ 9 files changed, 1846 insertions(+) create mode 100644 tests/test_check_remote.py create mode 100644 tests/test_downloader.py create mode 100644 tests/test_entrypoint.py create mode 100644 tests/test_filesystem.py create mode 100644 tests/test_process_manager.py create mode 100644 tests/test_updater.py create mode 100644 tests/utils/test_logger.py create mode 100644 tests/utils/test_notify.py diff --git a/pyproject.toml b/pyproject.toml index 1d0d0245..9aa57d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ dev = [ requires = ["uv_build>=0.11.7,<0.12.0"] build-backend = "uv_build" +[tool.coverage.run] +omit = ["tests/*"] + [tool.ruff] indent-width = 4 line-length = 120 diff --git a/tests/test_check_remote.py b/tests/test_check_remote.py new file mode 100644 index 00000000..9c71d00b --- /dev/null +++ b/tests/test_check_remote.py @@ -0,0 +1,209 @@ +import datetime +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from requests.exceptions import RequestException + +from src import check_remote +from src.utils import config + + +@pytest.fixture +def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + data_dir = tmp_path / "data" + photon_data_dir = data_dir / "photon_data" + os_node_dir = photon_data_dir / "node_1" + data_dir.mkdir() + photon_data_dir.mkdir() + monkeypatch.setattr(config, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(config, "PHOTON_DATA_DIR", str(photon_data_dir)) + monkeypatch.setattr(config, "OS_NODE_DIR", str(os_node_dir)) + return data_dir + + +def _mock_response(status_code=200, headers=None): + resp = MagicMock() + resp.status_code = status_code + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + return resp + + +def test_get_remote_file_size_uses_content_length(): + resp = _mock_response(headers={"content-length": "12345"}) + with patch("src.check_remote.requests.head", return_value=resp): + assert check_remote.get_remote_file_size("https://example.com/x") == 12345 + + +def test_get_remote_file_size_falls_back_to_range_request(): + head_resp = _mock_response(headers={}) + range_resp = _mock_response(status_code=206, headers={"content-range": "bytes 0-0/9876"}) + with ( + patch("src.check_remote.requests.head", return_value=head_resp), + patch("src.check_remote.requests.get", return_value=range_resp), + ): + assert check_remote.get_remote_file_size("https://example.com/x") == 9876 + + +def test_get_remote_file_size_raises_when_no_size_returned(): + head_resp = _mock_response(headers={}) + range_resp = _mock_response(status_code=200, headers={}) + with ( + patch("src.check_remote.requests.head", return_value=head_resp), + patch("src.check_remote.requests.get", return_value=range_resp), + pytest.raises(check_remote.RemoteFileSizeError, match="did not return file size"), + ): + check_remote.get_remote_file_size("https://example.com/x") + + +def test_get_remote_file_size_wraps_request_errors(): + with ( + patch("src.check_remote.requests.head", side_effect=RequestException("boom")), + pytest.raises(check_remote.RemoteFileSizeError, match="Could not determine remote file size"), + ): + check_remote.get_remote_file_size("https://example.com/x") + + +def test_get_remote_file_size_ignores_non_digit_range_total(): + head_resp = _mock_response(headers={}) + range_resp = _mock_response(status_code=206, headers={"content-range": "bytes 0-0/*"}) + with ( + patch("src.check_remote.requests.head", return_value=head_resp), + patch("src.check_remote.requests.get", return_value=range_resp), + pytest.raises(check_remote.RemoteFileSizeError), + ): + check_remote.get_remote_file_size("https://example.com/x") + + +def test_get_remote_time_returns_parsed_datetime(): + resp = _mock_response(headers={"last-modified": "Wed, 21 Oct 2026 07:28:00 GMT"}) + with patch("src.check_remote.requests.head", return_value=resp): + result = check_remote.get_remote_time("https://example.com") + assert result is not None + assert result.year == 2026 + assert result.month == 10 + assert result.day == 21 + + +def test_get_remote_time_returns_none_when_header_missing(): + resp = _mock_response(headers={}) + with patch("src.check_remote.requests.head", return_value=resp): + assert check_remote.get_remote_time("https://example.com") is None + + +def test_get_remote_time_returns_none_on_request_error(): + with patch("src.check_remote.requests.head", side_effect=RequestException("nope")): + assert check_remote.get_remote_time("https://example.com") is None + + +def test_get_local_time_returns_marker_mtime_when_present(fake_dirs: Path): + marker = fake_dirs / ".photon-index-updated" + marker.write_text("") + os.utime(marker, (1_000_000, 1_000_000)) + assert check_remote.get_local_time(str(fake_dirs / "missing")) == 1_000_000 + + +def test_get_local_time_returns_path_mtime_when_no_marker(fake_dirs: Path): + target = fake_dirs / "node_1" + target.mkdir() + os.utime(target, (2_000_000, 2_000_000)) + assert check_remote.get_local_time(str(target)) == 2_000_000 + + +def test_get_local_time_returns_zero_when_path_missing(fake_dirs: Path): + assert check_remote.get_local_time(str(fake_dirs / "missing")) == 0.0 + + +def test_compare_mtime_returns_false_when_remote_time_unknown(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "REGION", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com") + with patch("src.check_remote.get_remote_time", return_value=None): + assert check_remote.compare_mtime() is False + + +def test_compare_mtime_returns_false_on_invalid_region(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "REGION", "atlantis") + assert check_remote.compare_mtime() is False + + +def test_compare_mtime_with_marker_compares_directly(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "REGION", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com") + + marker = fake_dirs / ".photon-index-updated" + marker.write_text("") + local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp() + os.utime(marker, (local_ts, local_ts)) + + remote_dt = datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC) + with patch("src.check_remote.get_remote_time", return_value=remote_dt): + assert check_remote.compare_mtime() is True + + +def test_compare_mtime_with_marker_returns_false_when_remote_older(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "REGION", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com") + + marker = fake_dirs / ".photon-index-updated" + marker.write_text("") + local_ts = datetime.datetime(2026, 1, 10, tzinfo=datetime.UTC).timestamp() + os.utime(marker, (local_ts, local_ts)) + + remote_dt = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) + with patch("src.check_remote.get_remote_time", return_value=remote_dt): + assert check_remote.compare_mtime() is False + + +def test_compare_mtime_directory_uses_grace_period(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "REGION", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com") + + node_dir = Path(config.OS_NODE_DIR) + node_dir.mkdir() + local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp() + os.utime(node_dir, (local_ts, local_ts)) + + remote_within_grace = datetime.datetime(2026, 1, 5, tzinfo=datetime.UTC) + with patch("src.check_remote.get_remote_time", return_value=remote_within_grace): + assert check_remote.compare_mtime() is False + + remote_past_grace = datetime.datetime(2026, 1, 20, tzinfo=datetime.UTC) + with patch("src.check_remote.get_remote_time", return_value=remote_past_grace): + assert check_remote.compare_mtime() is True + + +def test_check_index_age_returns_true_when_min_date_unset(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MIN_INDEX_DATE", None) + assert check_remote.check_index_age() is True + + +def test_check_index_age_warns_and_returns_true_on_bad_format(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MIN_INDEX_DATE", "2026-01-01") + assert check_remote.check_index_age() is True + + +def test_check_index_age_returns_true_when_no_local_index(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + assert check_remote.check_index_age() is True + + +def test_check_index_age_returns_true_when_update_required_due_to_old_index( + fake_dirs: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.06.26") + node_dir = Path(config.OS_NODE_DIR) + node_dir.mkdir() + local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp() + os.utime(node_dir, (local_ts, local_ts)) + assert check_remote.check_index_age() is True + + +def test_check_index_age_returns_false_when_local_meets_min(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + node_dir = Path(config.OS_NODE_DIR) + node_dir.mkdir() + local_ts = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC).timestamp() + os.utime(node_dir, (local_ts, local_ts)) + assert check_remote.check_index_age() is False diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 00000000..c55f8e8f --- /dev/null +++ b/tests/test_downloader.py @@ -0,0 +1,493 @@ +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from requests.exceptions import RequestException + +from src import downloader +from src.utils import config + + +@pytest.fixture +def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + data_dir = tmp_path / "data" + photon_data_dir = data_dir / "photon_data" + temp_dir = data_dir / "temp" + os_node_dir = photon_data_dir / "node_1" + data_dir.mkdir() + + monkeypatch.setattr(config, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(config, "PHOTON_DATA_DIR", str(photon_data_dir)) + monkeypatch.setattr(config, "TEMP_DIR", str(temp_dir)) + monkeypatch.setattr(config, "OS_NODE_DIR", str(os_node_dir)) + return data_dir + + +def _mock_response(status_code=200, headers=None, chunks=None): + resp = MagicMock() + resp.status_code = status_code + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + resp.iter_content = MagicMock(return_value=iter(chunks or [])) + resp.__enter__ = MagicMock(return_value=resp) + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def test_get_available_space_returns_bytes(tmp_path: Path): + space = downloader.get_available_space(str(tmp_path)) + assert space > 0 + + +def test_get_available_space_returns_zero_on_invalid_path(): + assert downloader.get_available_space("/this/does/not/exist") == 0 + + +def test_check_disk_space_requirements_parallel_passes(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(downloader, "get_available_space", lambda _: 100 * 1024**3) + assert downloader.check_disk_space_requirements(10 * 1024**3, is_parallel=True) is True + + +def test_check_disk_space_requirements_parallel_fails_on_temp(monkeypatch: pytest.MonkeyPatch): + sizes = iter([1, 100 * 1024**3]) + monkeypatch.setattr(downloader, "get_available_space", lambda _: next(sizes)) + assert downloader.check_disk_space_requirements(10 * 1024**3, is_parallel=True) is False + + +def test_check_disk_space_requirements_parallel_fails_on_data(monkeypatch: pytest.MonkeyPatch): + sizes = iter([100 * 1024**3, 1]) + monkeypatch.setattr(downloader, "get_available_space", lambda _: next(sizes)) + assert downloader.check_disk_space_requirements(10 * 1024**3, is_parallel=True) is False + + +def test_check_disk_space_requirements_sequential_passes(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(downloader, "get_available_space", lambda _: 100 * 1024**3) + assert downloader.check_disk_space_requirements(10 * 1024**3, is_parallel=False) is True + + +def test_check_disk_space_requirements_sequential_fails(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(downloader, "get_available_space", lambda _: 1) + assert downloader.check_disk_space_requirements(10 * 1024**3, is_parallel=False) is False + + +def test_get_download_state_file_appends_suffix(): + assert downloader.get_download_state_file("/x/y/file.bin") == "/x/y/file.bin.download_state" + + +def test_save_and_load_download_state_roundtrip(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"hello") + downloader.save_download_state(str(dest), "https://example.com/x", 5, 100) + state = downloader.load_download_state(str(dest)) + assert state["url"] == "https://example.com/x" + assert state["downloaded_bytes"] == 5 + assert state["total_size"] == 100 + assert state["file_size"] == 5 + + +def test_load_download_state_returns_empty_when_no_state_file(tmp_path: Path): + assert downloader.load_download_state(str(tmp_path / "nope")) == {} + + +def test_load_download_state_resyncs_with_actual_file_size(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"x" * 200) + state_file = Path(downloader.get_download_state_file(str(dest))) + state_file.write_text(json.dumps({"url": "u", "downloaded_bytes": 50, "total_size": 1000, "file_size": 50})) + + state = downloader.load_download_state(str(dest)) + assert state["downloaded_bytes"] == 200 + assert state["file_size"] == 200 + + +def test_load_download_state_drops_state_when_file_smaller(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"x" * 10) + state_file = Path(downloader.get_download_state_file(str(dest))) + state_file.write_text(json.dumps({"url": "u", "downloaded_bytes": 50, "total_size": 1000, "file_size": 50})) + + assert downloader.load_download_state(str(dest)) == {} + assert not state_file.exists() + + +def test_load_download_state_handles_corrupted_state(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"data") + state_file = Path(downloader.get_download_state_file(str(dest))) + state_file.write_text("{not json") + + assert downloader.load_download_state(str(dest)) == {} + assert not state_file.exists() + + +def test_cleanup_download_state_removes_file(tmp_path: Path): + dest = tmp_path / "file.bin" + state_file = Path(downloader.get_download_state_file(str(dest))) + state_file.write_text("{}") + downloader.cleanup_download_state(str(dest)) + assert not state_file.exists() + + +def test_cleanup_download_state_no_op_when_missing(tmp_path: Path): + downloader.cleanup_download_state(str(tmp_path / "missing")) + + +def test_cleanup_download_state_swallows_remove_errors(tmp_path: Path): + dest = tmp_path / "file.bin" + state_file = Path(downloader.get_download_state_file(str(dest))) + state_file.write_text("{}") + with patch("src.downloader.os.remove", side_effect=OSError("locked")): + downloader.cleanup_download_state(str(dest)) + + +def test_supports_range_requests_true(): + resp = _mock_response(headers={"accept-ranges": "bytes"}) + with patch("src.downloader.requests.head", return_value=resp): + assert downloader.supports_range_requests("https://example.com/x") is True + + +def test_supports_range_requests_false_when_header_missing(): + resp = _mock_response(headers={}) + with patch("src.downloader.requests.head", return_value=resp): + assert downloader.supports_range_requests("https://example.com/x") is False + + +def test_supports_range_requests_false_on_error(): + with patch("src.downloader.requests.head", side_effect=RequestException("nope")): + assert downloader.supports_range_requests("https://example.com/x") is False + + +def test_get_download_url_uses_file_url_when_set(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FILE_URL", "https://override.example/file.tar.bz2") + assert downloader.get_download_url() == "https://override.example/file.tar.bz2" + + +def test_get_download_url_constructs_from_region_and_base(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FILE_URL", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com/public") + monkeypatch.setattr(config, "REGION", "europe") + monkeypatch.setattr(config, "INDEX_DB_VERSION", "1.0") + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + url = downloader.get_download_url() + assert url == "https://example.com/public/europe/photon-db-europe-1.0-latest.tar.bz2" + + +def test_prepare_download_no_state(tmp_path: Path): + dest = tmp_path / "file.bin" + pos, mode = downloader._prepare_download("https://example.com/x", str(dest)) + assert pos == 0 + assert mode == "wb" + + +def test_prepare_download_resumes_when_state_matches(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"x" * 50) + downloader.save_download_state(str(dest), "https://example.com/x", 50, 100) + + pos, mode = downloader._prepare_download("https://example.com/x", str(dest)) + assert pos == 50 + assert mode == "ab" + + +def test_prepare_download_starts_fresh_when_url_changed(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"x" * 50) + downloader.save_download_state(str(dest), "https://old.example.com/x", 50, 100) + + pos, mode = downloader._prepare_download("https://new.example.com/x", str(dest)) + assert pos == 0 + assert mode == "wb" + + +def test_get_download_headers_returns_range_when_resuming(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(downloader, "supports_range_requests", lambda _: True) + assert downloader._get_download_headers(123, "https://example.com/x") == {"Range": "bytes=123-"} + + +def test_get_download_headers_empty_when_no_resume(): + assert downloader._get_download_headers(0, "https://example.com/x") == {} + + +def test_get_download_headers_empty_when_no_range_support(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(downloader, "supports_range_requests", lambda _: False) + assert downloader._get_download_headers(123, "https://example.com/x") == {} + + +def test_calculate_total_size_with_range_response_using_content_range(): + resp = _mock_response(status_code=206, headers={"content-range": "bytes 0-99/12345"}) + assert downloader._calculate_total_size(resp, {"Range": "bytes=0-"}, 0) == 12345 + + +def test_calculate_total_size_with_range_response_no_content_range(): + resp = _mock_response(status_code=206, headers={"content-length": "100"}) + assert downloader._calculate_total_size(resp, {"Range": "bytes=50-"}, 50) == 150 + + +def test_calculate_total_size_without_range_uses_content_length(): + resp = _mock_response(status_code=200, headers={"content-length": "9999"}) + assert downloader._calculate_total_size(resp, {}, 0) == 9999 + + +def test_handle_no_range_support_resets_when_resuming(tmp_path: Path): + dest = tmp_path / "file.bin" + dest.write_bytes(b"x" * 100) + pos, mode = downloader._handle_no_range_support(100, str(dest)) + assert pos == 0 + assert mode == "wb" + assert not dest.exists() + + +def test_handle_no_range_support_no_op_when_not_resuming(tmp_path: Path): + dest = tmp_path / "file.bin" + pos, mode = downloader._handle_no_range_support(0, str(dest)) + assert pos == 0 + assert mode is None + + +def test_create_progress_bar_returns_none_when_no_size(tmp_path: Path): + assert downloader._create_progress_bar(0, 0, str(tmp_path / "file.bin")) is None + + +def test_create_progress_bar_returns_tqdm_when_size_known(tmp_path: Path): + bar = downloader._create_progress_bar(1024, 0, str(tmp_path / "file.bin")) + assert bar is not None + bar.close() + + +def test_log_download_metrics_handles_long_download(caplog: pytest.LogCaptureFixture, tmp_path: Path): + import logging as _logging + import time + + caplog.set_level(_logging.INFO, logger="root") + downloader._log_download_metrics(10 * 1024**3, time.time() - (3 * 60 * 60), str(tmp_path / "f")) + msgs = "\n".join(r.message for r in caplog.records) + assert "Download completed" in msgs + assert "h" in msgs + + +def test_log_download_metrics_handles_short_download(caplog: pytest.LogCaptureFixture, tmp_path: Path): + import logging as _logging + import time + + caplog.set_level(_logging.INFO, logger="root") + downloader._log_download_metrics(1024**3, time.time() - 10, str(tmp_path / "f")) + assert any("Download completed" in r.message for r in caplog.records) + + +def test_log_download_metrics_no_size(caplog: pytest.LogCaptureFixture, tmp_path: Path): + import logging as _logging + + caplog.set_level(_logging.INFO, logger="root") + downloader._log_download_metrics(0, 0.0, str(tmp_path / "f")) + assert any("successfully" in r.message for r in caplog.records) + + +def test_download_file_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + dest = tmp_path / "out.bin" + monkeypatch.setattr(config, "DOWNLOAD_MAX_RETRIES", "1") + + resp = _mock_response(status_code=200, headers={"content-length": "5"}, chunks=[b"hello"]) + with patch("src.downloader.requests.get", return_value=resp): + assert downloader.download_file("https://example.com/x", str(dest)) is True + + assert dest.read_bytes() == b"hello" + assert not Path(downloader.get_download_state_file(str(dest))).exists() + + +def test_download_file_incomplete_raises_and_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + dest = tmp_path / "out.bin" + monkeypatch.setattr(config, "DOWNLOAD_MAX_RETRIES", "1") + + resp = _mock_response(status_code=200, headers={"content-length": "10"}, chunks=[b"hi"]) + with patch("src.downloader.requests.get", return_value=resp): + assert downloader.download_file("https://example.com/x", str(dest)) is False + + +def test_download_file_retries_on_request_exception(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + dest = tmp_path / "out.bin" + monkeypatch.setattr(config, "DOWNLOAD_MAX_RETRIES", "3") + monkeypatch.setattr(downloader.time, "sleep", lambda *_: None) + + good_resp = _mock_response(status_code=200, headers={"content-length": "3"}, chunks=[b"abc"]) + + calls = {"n": 0} + + def fake_get(*_args, **_kwargs): + calls["n"] += 1 + if calls["n"] < 3: + raise RequestException("transient") + return good_resp + + with patch("src.downloader.requests.get", side_effect=fake_get): + assert downloader.download_file("https://example.com/x", str(dest)) is True + assert calls["n"] == 3 + + +def test_download_file_returns_false_when_retries_exhausted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + dest = tmp_path / "out.bin" + monkeypatch.setattr(config, "DOWNLOAD_MAX_RETRIES", "2") + monkeypatch.setattr(downloader.time, "sleep", lambda *_: None) + with patch("src.downloader.requests.get", side_effect=RequestException("always")): + assert downloader.download_file("https://example.com/x", str(dest)) is False + + +def test_download_file_returns_false_on_unexpected_exception(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + dest = tmp_path / "out.bin" + monkeypatch.setattr(config, "DOWNLOAD_MAX_RETRIES", "1") + with patch("src.downloader.requests.get", side_effect=RuntimeError("boom")): + assert downloader.download_file("https://example.com/x", str(dest)) is False + + +def test_download_index_returns_path(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + monkeypatch.setattr(downloader, "get_download_url", lambda: "https://example.com/x") + Path(config.TEMP_DIR).mkdir(parents=True, exist_ok=True) + + def fake_download(_url, output): + Path(output).write_bytes(b"x") + return True + + with patch("src.downloader.download_file", side_effect=fake_download): + out = downloader.download_index() + + assert out == os.path.join(config.TEMP_DIR, "photon-db-latest.tar.bz2") + assert Path(out).exists() + + +def test_download_index_raises_on_failure(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + monkeypatch.setattr(downloader, "get_download_url", lambda: "https://example.com/x") + Path(config.TEMP_DIR).mkdir(parents=True, exist_ok=True) + with ( + patch("src.downloader.download_file", return_value=False), + pytest.raises(Exception, match="Failed to download index"), + ): + downloader.download_index() + + +def test_download_md5_uses_explicit_url(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MD5_URL", "https://example.com/custom.md5") + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + Path(config.TEMP_DIR).mkdir(parents=True, exist_ok=True) + + captured = {} + + def fake_download(url, output): + captured["url"] = url + captured["output"] = output + Path(output).write_text("md5") + return True + + with patch("src.downloader.download_file", side_effect=fake_download): + out = downloader.download_md5() + + assert captured["url"] == "https://example.com/custom.md5" + assert out.endswith("photon-db-latest.tar.bz2.md5") + + +def test_download_md5_constructs_url_when_unset(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MD5_URL", None) + monkeypatch.setattr(config, "FILE_URL", None) + monkeypatch.setattr(config, "BASE_URL", "https://example.com/public") + monkeypatch.setattr(config, "REGION", None) + monkeypatch.setattr(config, "INDEX_DB_VERSION", "1.0") + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + Path(config.TEMP_DIR).mkdir(parents=True, exist_ok=True) + + captured = {} + + def fake_download(url, output): + captured["url"] = url + Path(output).write_text("md5") + return True + + with patch("src.downloader.download_file", side_effect=fake_download): + downloader.download_md5() + + assert captured["url"] == "https://example.com/public/photon-db-planet-1.0-latest.tar.bz2.md5" + + +def test_download_md5_raises_on_failure(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "MD5_URL", "https://example.com/x.md5") + monkeypatch.setattr(config, "INDEX_FILE_EXTENSION", "tar.bz2") + Path(config.TEMP_DIR).mkdir(parents=True, exist_ok=True) + with ( + patch("src.downloader.download_file", return_value=False), + pytest.raises(Exception, match="Failed to download MD5"), + ): + downloader.download_md5() + + +def _make_orchestrator_patches(monkeypatch: pytest.MonkeyPatch): + fake_index = str(Path(config.TEMP_DIR) / "index.tar.bz2") + fake_md5 = fake_index + ".md5" + monkeypatch.setattr(downloader, "get_download_url", lambda: "https://example.com/x") + monkeypatch.setattr(downloader, "get_remote_file_size", lambda _: 1024) + monkeypatch.setattr(downloader, "check_disk_space_requirements", lambda *_, **__: True) + monkeypatch.setattr(downloader, "download_index", lambda: fake_index) + monkeypatch.setattr(downloader, "download_md5", lambda: fake_md5) + monkeypatch.setattr(downloader, "extract_index", lambda _: None) + monkeypatch.setattr(downloader, "verify_checksum", lambda *_: True) + monkeypatch.setattr(downloader, "move_index", lambda: True) + monkeypatch.setattr(downloader, "clear_temp_dir", lambda: None) + + +def test_parallel_update_happy_path(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "SKIP_MD5_CHECK", False) + _make_orchestrator_patches(monkeypatch) + downloader.parallel_update() + assert Path(config.TEMP_DIR).exists() + + +def test_parallel_update_skips_md5_when_configured(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "SKIP_MD5_CHECK", True) + _make_orchestrator_patches(monkeypatch) + + md5_called = {"n": 0} + + def fake_md5(): + md5_called["n"] += 1 + return str(Path(config.TEMP_DIR) / "x.md5") + + monkeypatch.setattr(downloader, "download_md5", fake_md5) + downloader.parallel_update() + assert md5_called["n"] == 0 + + +def test_parallel_update_raises_on_insufficient_space(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + _make_orchestrator_patches(monkeypatch) + monkeypatch.setattr(downloader, "check_disk_space_requirements", lambda *_, **__: False) + with pytest.raises(SystemExit): + downloader.parallel_update() + + +def test_parallel_update_skip_space_check_proceeds_on_size_error(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "SKIP_SPACE_CHECK", True) + monkeypatch.setattr(config, "SKIP_MD5_CHECK", True) + _make_orchestrator_patches(monkeypatch) + + def boom(_url): + raise downloader.RemoteFileSizeError("no size") + + monkeypatch.setattr(downloader, "get_remote_file_size", boom) + downloader.parallel_update() + + +def test_sequential_update_happy_path(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "SKIP_MD5_CHECK", False) + _make_orchestrator_patches(monkeypatch) + downloader.sequential_update() + + +def test_sequential_update_raises_on_size_error_without_skip(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "SKIP_SPACE_CHECK", False) + _make_orchestrator_patches(monkeypatch) + + def boom(_url): + raise downloader.RemoteFileSizeError("no size") + + monkeypatch.setattr(downloader, "get_remote_file_size", boom) + with pytest.raises(SystemExit): + downloader.sequential_update() diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 00000000..7ff51d4e --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,208 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src import entrypoint +from src.downloader import InsufficientSpaceError +from src.utils import config + + +@pytest.fixture +def base_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + os_node_dir = tmp_path / "node_1" + monkeypatch.setattr(config, "OS_NODE_DIR", str(os_node_dir)) + monkeypatch.setattr(config, "FORCE_UPDATE", False) + monkeypatch.setattr(config, "INITIAL_DOWNLOAD", True) + monkeypatch.setattr(config, "MIN_INDEX_DATE", None) + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + monkeypatch.setattr(config, "FILE_URL", None) + monkeypatch.setattr(config, "MD5_URL", None) + monkeypatch.setattr(config, "APPRISE_URLS", None) + return os_node_dir + + +def _patch_common(): + return (patch("src.entrypoint.send_notification"), patch("src.entrypoint.validate_config")) + + +def test_entrypoint_skips_download_when_index_present(base_config: Path): + base_config.mkdir() + notify, validate = _patch_common() + with ( + notify as n, + validate as v, + patch("src.entrypoint.sequential_update") as seq, + patch("src.entrypoint.parallel_update") as par, + ): + entrypoint.main() + + n.assert_called() + v.assert_called_once() + seq.assert_not_called() + par.assert_not_called() + + +def test_entrypoint_runs_initial_sequential_when_no_index(base_config: Path): + notify, validate = _patch_common() + with notify, validate, patch("src.entrypoint.sequential_update") as seq: + entrypoint.main() + seq.assert_called_once() + + +def test_entrypoint_skips_initial_when_disabled(base_config: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "INITIAL_DOWNLOAD", False) + notify, validate = _patch_common() + with notify, validate, patch("src.entrypoint.sequential_update") as seq: + entrypoint.main() + seq.assert_not_called() + + +def test_entrypoint_force_update_uses_parallel_when_set(base_config: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FORCE_UPDATE", True) + monkeypatch.setattr(config, "UPDATE_STRATEGY", "PARALLEL") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.parallel_update") as par, + patch("src.entrypoint.sequential_update") as seq, + ): + entrypoint.main() + par.assert_called_once() + seq.assert_not_called() + + +def test_entrypoint_force_update_uses_sequential_when_not_parallel(base_config: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FORCE_UPDATE", True) + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.sequential_update") as seq, + patch("src.entrypoint.parallel_update") as par, + ): + entrypoint.main() + seq.assert_called_once() + par.assert_not_called() + + +def test_entrypoint_force_update_exits_on_insufficient_space(base_config: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FORCE_UPDATE", True) + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.sequential_update", side_effect=InsufficientSpaceError("no space")), + pytest.raises(SystemExit) as exc, + ): + entrypoint.main() + assert exc.value.code == 75 + + +def test_entrypoint_force_update_propagates_unexpected_error(base_config: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "FORCE_UPDATE", True) + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.sequential_update", side_effect=RuntimeError("boom")), + pytest.raises(RuntimeError), + ): + entrypoint.main() + + +def test_entrypoint_initial_download_exits_on_insufficient_space(base_config: Path): + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.sequential_update", side_effect=InsufficientSpaceError("no space")), + pytest.raises(SystemExit) as exc, + ): + entrypoint.main() + assert exc.value.code == 75 + + +def test_entrypoint_validate_config_failure_exits(base_config: Path): + base_config.mkdir() + with ( + patch("src.entrypoint.send_notification"), + patch("src.entrypoint.validate_config", side_effect=ValueError("bad")), + pytest.raises(SystemExit) as exc, + ): + entrypoint.main() + assert exc.value.code == 1 + + +def test_entrypoint_min_date_triggers_update(base_config: Path, monkeypatch: pytest.MonkeyPatch): + base_config.mkdir() + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.check_index_age", return_value=True), + patch("src.entrypoint.sequential_update") as seq, + ): + entrypoint.main() + seq.assert_called_once() + + +def test_entrypoint_min_date_skips_when_index_recent(base_config: Path, monkeypatch: pytest.MonkeyPatch): + base_config.mkdir() + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.check_index_age", return_value=False), + patch("src.entrypoint.sequential_update") as seq, + ): + entrypoint.main() + seq.assert_not_called() + + +def test_entrypoint_min_date_exits_on_insufficient_space(base_config: Path, monkeypatch: pytest.MonkeyPatch): + base_config.mkdir() + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.check_index_age", return_value=True), + patch("src.entrypoint.sequential_update", side_effect=InsufficientSpaceError("no")), + pytest.raises(SystemExit) as exc, + ): + entrypoint.main() + assert exc.value.code == 75 + + +def test_entrypoint_min_date_propagates_unexpected_error(base_config: Path, monkeypatch: pytest.MonkeyPatch): + base_config.mkdir() + monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26") + notify, validate = _patch_common() + with ( + notify, + validate, + patch("src.entrypoint.check_index_age", return_value=True), + patch("src.entrypoint.sequential_update", side_effect=RuntimeError("boom")), + pytest.raises(RuntimeError), + ): + entrypoint.main() + + +def test_entrypoint_logs_apprise_redacted_when_set( + base_config: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + import logging as _logging + + base_config.mkdir() + monkeypatch.setattr(config, "APPRISE_URLS", "tgram://abc") + caplog.set_level(_logging.INFO, logger="root") + with patch("src.entrypoint.send_notification"), patch("src.entrypoint.validate_config"): + entrypoint.main() + assert any("APPRISE_URLS: REDACTED" in r.message for r in caplog.records) diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 00000000..b871ebd7 --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,299 @@ +import hashlib +import os +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src import filesystem +from src.utils import config + + +@pytest.fixture +def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + data_dir = tmp_path / "data" + photon_data_dir = data_dir / "photon_data" + temp_dir = data_dir / "temp" + os_node_dir = photon_data_dir / "node_1" + data_dir.mkdir() + + monkeypatch.setattr(config, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(config, "PHOTON_DATA_DIR", str(photon_data_dir)) + monkeypatch.setattr(config, "TEMP_DIR", str(temp_dir)) + monkeypatch.setattr(config, "OS_NODE_DIR", str(os_node_dir)) + return data_dir + + +def test_verify_checksum_returns_true_on_match(tmp_path: Path): + index_file = tmp_path / "index.bin" + index_file.write_bytes(b"hello world") + expected = hashlib.md5(b"hello world").hexdigest() # noqa: S324 + md5_file = tmp_path / "index.bin.md5" + md5_file.write_text(f"{expected} index.bin\n") + + assert filesystem.verify_checksum(str(md5_file), str(index_file)) is True + + +def test_verify_checksum_raises_on_mismatch(tmp_path: Path): + index_file = tmp_path / "index.bin" + index_file.write_bytes(b"hello world") + md5_file = tmp_path / "index.bin.md5" + md5_file.write_text("00000000000000000000000000000000 index.bin\n") + + with pytest.raises(Exception, match="Checksum mismatch"): + filesystem.verify_checksum(str(md5_file), str(index_file)) + + +def test_verify_checksum_raises_when_index_missing(tmp_path: Path): + md5_file = tmp_path / "x.md5" + md5_file.write_text("0" * 32) + with pytest.raises(FileNotFoundError): + filesystem.verify_checksum(str(md5_file), str(tmp_path / "missing")) + + +def test_verify_checksum_raises_when_md5_missing(tmp_path: Path): + index_file = tmp_path / "index.bin" + index_file.write_bytes(b"data") + with pytest.raises(FileNotFoundError): + filesystem.verify_checksum(str(tmp_path / "missing.md5"), str(index_file)) + + +def test_verify_checksum_raises_on_empty_md5_file(tmp_path: Path): + index_file = tmp_path / "index.bin" + index_file.write_bytes(b"data") + md5_file = tmp_path / "empty.md5" + md5_file.write_text("") + with pytest.raises((IndexError, ValueError)): + filesystem.verify_checksum(str(md5_file), str(index_file)) + + +def test_clear_temp_dir_removes_existing_temp(fake_dirs: Path): + temp = Path(config.TEMP_DIR) + temp.mkdir() + (temp / "file.txt").write_text("x") + (temp / "sub").mkdir() + (temp / "sub" / "nested").write_text("y") + + filesystem.clear_temp_dir() + + assert not temp.exists() + + +def test_clear_temp_dir_handles_missing_temp_dir(fake_dirs: Path): + assert not Path(config.TEMP_DIR).exists() + filesystem.clear_temp_dir() + + +def test_update_timestamp_marker_creates_marker(fake_dirs: Path): + filesystem.update_timestamp_marker() + marker = Path(config.DATA_DIR) / ".photon-index-updated" + assert marker.exists() + + +def test_update_timestamp_marker_swallows_errors(fake_dirs: Path): + with patch("src.filesystem.Path.touch", side_effect=OSError("nope")): + filesystem.update_timestamp_marker() + + +def test_cleanup_staging_and_temp_backup_removes_both(tmp_path: Path): + staging = tmp_path / "staging" + backup = tmp_path / "backup" + staging.mkdir() + backup.mkdir() + (staging / "f").write_text("x") + (backup / "f").write_text("y") + + filesystem.cleanup_staging_and_temp_backup(str(staging), str(backup)) + + assert not staging.exists() + assert not backup.exists() + + +def test_cleanup_staging_and_temp_backup_no_op_when_missing(tmp_path: Path): + filesystem.cleanup_staging_and_temp_backup(str(tmp_path / "a"), str(tmp_path / "b")) + + +def test_cleanup_staging_and_temp_backup_swallows_rmtree_errors(tmp_path: Path): + staging = tmp_path / "staging" + staging.mkdir() + with patch("src.filesystem.shutil.rmtree", side_effect=OSError("locked")): + filesystem.cleanup_staging_and_temp_backup(str(staging), str(tmp_path / "missing")) + + +def test_cleanup_backup_after_verification_removes_backup(tmp_path: Path): + target = tmp_path / "node_1" + backup = Path(str(target) + ".backup") + backup.mkdir() + (backup / "x").write_text("x") + + assert filesystem.cleanup_backup_after_verification(str(target)) is True + assert not backup.exists() + + +def test_cleanup_backup_after_verification_returns_true_when_no_backup(tmp_path: Path): + target = tmp_path / "node_1" + assert filesystem.cleanup_backup_after_verification(str(target)) is True + + +def test_cleanup_backup_after_verification_returns_false_on_failure(tmp_path: Path): + target = tmp_path / "node_1" + backup = Path(str(target) + ".backup") + backup.mkdir() + with patch("src.filesystem.shutil.rmtree", side_effect=OSError("locked")): + assert filesystem.cleanup_backup_after_verification(str(target)) is False + + +def test_move_index_atomic_swaps_into_target(tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + (source / "data.txt").write_text("new") + + target = tmp_path / "target" + + assert filesystem.move_index_atomic(str(source), str(target)) is True + assert (target / "data.txt").read_text() == "new" + assert not source.exists() + assert not (tmp_path / "target.staging").exists() + + +def test_move_index_atomic_replaces_existing_target(tmp_path: Path): + target = tmp_path / "target" + target.mkdir() + (target / "old.txt").write_text("old") + + source = tmp_path / "source" + source.mkdir() + (source / "new.txt").write_text("new") + + assert filesystem.move_index_atomic(str(source), str(target)) is True + assert (target / "new.txt").read_text() == "new" + assert not (target / "old.txt").exists() + backup = Path(str(target) + ".backup") + assert backup.exists() + assert (backup / "old.txt").read_text() == "old" + + +def test_move_index_atomic_cleans_existing_staging_dir(tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + (source / "x.txt").write_text("x") + target = tmp_path / "target" + leftover_staging = Path(str(target) + ".staging") + leftover_staging.mkdir() + (leftover_staging / "stale.txt").write_text("stale") + + assert filesystem.move_index_atomic(str(source), str(target)) is True + assert (target / "x.txt").read_text() == "x" + assert not leftover_staging.exists() + + +def test_move_index_atomic_rolls_back_on_failure(tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + (source / "new.txt").write_text("new") + target = tmp_path / "target" + target.mkdir() + (target / "old.txt").write_text("old") + + real_rename = os.rename + call_count = {"n": 0} + + def fake_rename(src, dst): + call_count["n"] += 1 + if call_count["n"] == 2: + raise OSError("rename boom") + real_rename(src, dst) + + with patch("src.filesystem.os.rename", side_effect=fake_rename), pytest.raises(OSError, match="rename boom"): + filesystem.move_index_atomic(str(source), str(target)) + + assert (target / "old.txt").read_text() == "old" + assert not Path(str(target) + ".backup").exists() + + +def test_rollback_atomic_move_keeps_new_index_when_succeeded(tmp_path: Path): + target = tmp_path / "target" + target.mkdir() + (target / "fresh.txt").write_text("fresh") + + filesystem.rollback_atomic_move( + str(tmp_path / "source"), str(target), str(tmp_path / "staging"), str(tmp_path / "backup") + ) + + assert (target / "fresh.txt").read_text() == "fresh" + + +def test_rollback_atomic_move_swallows_inner_exceptions(tmp_path: Path): + target = tmp_path / "target" + target.mkdir() + backup = tmp_path / "backup" + backup.mkdir() + + with patch("src.filesystem.shutil.rmtree", side_effect=OSError("nope")): + filesystem.rollback_atomic_move(str(tmp_path / "source"), str(target), str(tmp_path / "staging"), str(backup)) + + +def test_move_index_calls_atomic_and_writes_marker(fake_dirs: Path): + temp_photon = Path(config.TEMP_DIR) / "photon_data" + temp_photon.mkdir(parents=True) + (temp_photon / "node_1").mkdir() + (temp_photon / "node_1" / "data.bin").write_text("payload") + + assert filesystem.move_index() is True + + marker = Path(config.DATA_DIR) / ".photon-index-updated" + assert marker.exists() + target = Path(config.PHOTON_DATA_DIR) + assert (target / "node_1" / "data.bin").read_text() == "payload" + + +def test_move_index_returns_false_when_atomic_returns_false(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(filesystem, "move_index_atomic", lambda *_: False) + assert filesystem.move_index() is False + marker = Path(config.DATA_DIR) / ".photon-index-updated" + assert not marker.exists() + + +def test_extract_index_runs_lbzip2_command(fake_dirs: Path): + index_file = Path(config.TEMP_DIR).parent / "index.tar.bz2" + index_file.parent.mkdir(parents=True, exist_ok=True) + index_file.write_bytes(b"x") + + completed = subprocess.CompletedProcess(args="cmd", returncode=0, stdout="ok", stderr="") + with patch("src.filesystem.subprocess.run", return_value=completed) as run: + filesystem.extract_index(str(index_file)) + + args, kwargs = run.call_args + assert "lbzip2 -d -c" in args[0] + assert str(index_file) in args[0] + assert kwargs["shell"] is True + assert kwargs["check"] is True + assert Path(config.TEMP_DIR).exists() + + +def test_extract_index_creates_temp_dir_when_missing(fake_dirs: Path): + index_file = Path(config.DATA_DIR) / "index.tar.bz2" + index_file.write_bytes(b"x") + + assert not Path(config.TEMP_DIR).exists() + completed = subprocess.CompletedProcess(args="cmd", returncode=0, stdout="", stderr="") + with patch("src.filesystem.subprocess.run", return_value=completed): + filesystem.extract_index(str(index_file)) + + assert Path(config.TEMP_DIR).exists() + + +def test_extract_index_propagates_called_process_error(fake_dirs: Path): + index_file = Path(config.DATA_DIR) / "index.tar.bz2" + index_file.write_bytes(b"x") + err = subprocess.CalledProcessError(returncode=1, cmd="lbzip2 ...", output="", stderr="boom") + with patch("src.filesystem.subprocess.run", side_effect=err), pytest.raises(subprocess.CalledProcessError): + filesystem.extract_index(str(index_file)) + + +def test_extract_index_propagates_unexpected_error(fake_dirs: Path): + index_file = Path(config.DATA_DIR) / "index.tar.bz2" + index_file.write_bytes(b"x") + with patch("src.filesystem.subprocess.run", side_effect=RuntimeError("nope")), pytest.raises(RuntimeError): + filesystem.extract_index(str(index_file)) diff --git a/tests/test_process_manager.py b/tests/test_process_manager.py new file mode 100644 index 00000000..29286ba9 --- /dev/null +++ b/tests/test_process_manager.py @@ -0,0 +1,441 @@ +import signal +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import schedule +from requests.exceptions import RequestException + +from src import process_manager +from src.utils import config + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(process_manager.time, "sleep", lambda *_: None) + + +@pytest.fixture(autouse=True) +def _stub_signal(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(process_manager.signal, "signal", lambda *_: None) + + +@pytest.fixture(autouse=True) +def _clear_schedule(): + schedule.clear() + yield + schedule.clear() + + +@pytest.fixture +def manager() -> process_manager.PhotonManager: + return process_manager.PhotonManager() + + +def _ok_response(): + resp = MagicMock() + resp.status_code = 200 + return resp + + +def test_check_photon_health_returns_true_on_200(): + with patch("src.process_manager.requests.get", return_value=_ok_response()): + assert process_manager.check_photon_health(timeout=1, max_retries=1) is True + + +def test_check_photon_health_returns_false_after_retries(): + bad = MagicMock() + bad.status_code = 500 + with patch("src.process_manager.requests.get", return_value=bad): + assert process_manager.check_photon_health(timeout=1, max_retries=2) is False + + +def test_check_photon_health_handles_request_exception(): + with patch("src.process_manager.requests.get", side_effect=RequestException("nope")): + assert process_manager.check_photon_health(timeout=1, max_retries=2) is False + + +def test_wait_for_photon_ready_true_when_health_ok(): + with patch("src.process_manager.check_photon_health", return_value=True): + assert process_manager.wait_for_photon_ready(timeout=1) is True + + +def test_wait_for_photon_ready_false_on_timeout(monkeypatch: pytest.MonkeyPatch): + times = iter([0, 0, 999]) + + def fake_time(): + return next(times) + + monkeypatch.setattr(process_manager.time, "time", fake_time) + with patch("src.process_manager.check_photon_health", return_value=False): + assert process_manager.wait_for_photon_ready(timeout=1) is False + + +def test_handle_shutdown_sets_exit_and_calls_shutdown(manager: process_manager.PhotonManager): + with patch.object(manager, "shutdown") as shutdown: + manager.handle_shutdown(signal.SIGTERM, None) + assert manager.should_exit is True + shutdown.assert_called_once() + + +def test_run_initial_setup_exits_on_failure(manager: process_manager.PhotonManager): + completed = subprocess.CompletedProcess(args=[], returncode=1) + with patch("src.process_manager.subprocess.run", return_value=completed), pytest.raises(SystemExit) as exc: + manager.run_initial_setup() + assert exc.value.code == 1 + + +def test_run_initial_setup_succeeds_on_zero_exit(manager: process_manager.PhotonManager): + completed = subprocess.CompletedProcess(args=[], returncode=0) + with patch("src.process_manager.subprocess.run", return_value=completed): + manager.run_initial_setup() + + +def test_start_photon_builds_full_command(manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "ENABLE_METRICS", True) + monkeypatch.setattr(config, "JAVA_PARAMS", "-Xmx4g") + monkeypatch.setattr(config, "PHOTON_PARAMS", "-cors-any") + monkeypatch.setattr(config, "PHOTON_LISTEN_IP", "127.0.0.1") + monkeypatch.setattr(config, "DATA_DIR", "/data") + + fake_proc = MagicMock() + fake_proc.pid = 1234 + with ( + patch("src.process_manager.subprocess.Popen", return_value=fake_proc) as popen, + patch("src.process_manager.wait_for_photon_ready", return_value=True), + ): + assert manager.start_photon(max_startup_retries=1) is True + + cmd = popen.call_args.args[0] + assert cmd[0] == "java" + assert "-Xmx4g" in cmd + assert "-cors-any" in cmd + assert "/photon/photon.jar" in cmd + assert "-listen-ip" in cmd and "127.0.0.1" in cmd + assert "-data-dir" in cmd and "/data" in cmd + assert "-metrics-enable" in cmd and "prometheus" in cmd + + +def test_start_photon_retries_until_failure(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.pid = 1 + with ( + patch("src.process_manager.subprocess.Popen", return_value=fake_proc) as popen, + patch("src.process_manager.wait_for_photon_ready", return_value=False), + patch.object(manager, "stop_photon"), + ): + assert manager.start_photon(max_startup_retries=3) is False + assert popen.call_count == 3 + + +def test_stop_photon_no_op_when_no_process(manager: process_manager.PhotonManager): + manager.photon_process = None + manager.stop_photon() + + +def test_stop_photon_sigterm_path(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.pid = 4321 + fake_proc.wait = MagicMock() + manager.photon_process = fake_proc + with ( + patch("src.process_manager.os.killpg") as killpg, + patch("src.process_manager.os.getpgid", return_value=99), + patch.object(manager, "cleanup_orphaned_photon_processes"), + patch.object(manager, "_cleanup_lock_files"), + ): + manager.stop_photon() + killpg.assert_called_once_with(99, signal.SIGTERM) + assert manager.photon_process is None + + +def test_stop_photon_force_kills_on_timeout(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.pid = 4321 + fake_proc.wait.side_effect = [subprocess.TimeoutExpired(cmd="x", timeout=30), None] + manager.photon_process = fake_proc + + with ( + patch("src.process_manager.os.killpg") as killpg, + patch("src.process_manager.os.getpgid", return_value=99), + patch.object(manager, "cleanup_orphaned_photon_processes"), + patch.object(manager, "_cleanup_lock_files"), + ): + manager.stop_photon() + signals = [c.args[1] for c in killpg.call_args_list] + assert signal.SIGTERM in signals and signal.SIGKILL in signals + + +def test_stop_photon_handles_lookup_error(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.pid = 4321 + manager.photon_process = fake_proc + with ( + patch("src.process_manager.os.killpg", side_effect=ProcessLookupError), + patch("src.process_manager.os.getpgid", return_value=99), + patch.object(manager, "cleanup_orphaned_photon_processes"), + patch.object(manager, "_cleanup_lock_files"), + ): + manager.stop_photon() + assert manager.photon_process is None + + +def test_cleanup_orphaned_photon_processes_terminates_matches(manager: process_manager.PhotonManager): + proc_a = MagicMock() + proc_a.info = {"pid": 1, "name": "java", "cmdline": ["java", "-jar", "/photon/photon.jar"]} + proc_b = MagicMock() + proc_b.info = {"pid": 2, "name": "python", "cmdline": ["python", "x"]} + proc_c = MagicMock() + proc_c.info = {"pid": 3, "name": "java", "cmdline": ["java", "-jar", "other.jar"]} + + with patch("src.process_manager.psutil.process_iter", return_value=[proc_a, proc_b, proc_c]): + manager.cleanup_orphaned_photon_processes() + + proc_a.terminate.assert_called_once() + proc_b.terminate.assert_not_called() + proc_c.terminate.assert_not_called() + + +def test_cleanup_orphaned_photon_processes_kills_on_timeout(manager: process_manager.PhotonManager): + import psutil + + proc = MagicMock() + proc.info = {"pid": 1, "name": "java", "cmdline": ["java", "-jar", "/photon/photon.jar"]} + proc.wait.side_effect = psutil.TimeoutExpired(seconds=5) + + with patch("src.process_manager.psutil.process_iter", return_value=[proc]): + manager.cleanup_orphaned_photon_processes() + proc.kill.assert_called_once() + + +def test_cleanup_orphaned_photon_processes_swallows_exceptions(manager: process_manager.PhotonManager): + with patch("src.process_manager.psutil.process_iter", side_effect=RuntimeError("nope")): + manager.cleanup_orphaned_photon_processes() + + +def test_cleanup_lock_files_removes_existing( + manager: process_manager.PhotonManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + node = tmp_path / "node_1" + node.mkdir() + (node / "node.lock").write_text("") + (node / "data").mkdir() + (node / "data" / "node.lock").write_text("") + + monkeypatch.setattr(config, "OS_NODE_DIR", str(node)) + manager._cleanup_lock_files() + assert not (node / "node.lock").exists() + assert not (node / "data" / "node.lock").exists() + + +def test_cleanup_lock_files_swallows_remove_errors( + manager: process_manager.PhotonManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + node = tmp_path / "node_1" + node.mkdir() + (node / "node.lock").write_text("") + monkeypatch.setattr(config, "OS_NODE_DIR", str(node)) + with patch("src.process_manager.os.remove", side_effect=OSError("locked")): + manager._cleanup_lock_files() + + +def test_run_update_skips_when_disabled(manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "DISABLED") + with patch("src.process_manager.subprocess.run") as run: + manager.run_update() + run.assert_not_called() + + +def test_run_update_no_op_when_index_up_to_date( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + with ( + patch("src.process_manager.compare_mtime", return_value=False), + patch("src.process_manager.subprocess.run") as run, + ): + manager.run_update() + run.assert_not_called() + assert manager.state == process_manager.AppState.RUNNING + + +def test_run_update_parallel_path(manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "PARALLEL") + completed = subprocess.CompletedProcess(args=[], returncode=0) + with ( + patch("src.process_manager.compare_mtime", return_value=True), + patch("src.process_manager.subprocess.run", return_value=completed), + patch.object(manager, "stop_photon") as stop, + patch.object(manager, "start_photon", return_value=True) as start, + patch("src.process_manager.cleanup_backup_after_verification") as cleanup, + ): + manager.run_update() + stop.assert_called_once() + start.assert_called_once() + cleanup.assert_called_once() + + +def test_run_update_parallel_logs_failure_when_health_check_fails( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "PARALLEL") + completed = subprocess.CompletedProcess(args=[], returncode=0) + with ( + patch("src.process_manager.compare_mtime", return_value=True), + patch("src.process_manager.subprocess.run", return_value=completed), + patch.object(manager, "stop_photon"), + patch.object(manager, "start_photon", return_value=False), + patch("src.process_manager.cleanup_backup_after_verification") as cleanup, + ): + manager.run_update() + cleanup.assert_not_called() + + +def test_run_update_sequential_path(manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + completed = subprocess.CompletedProcess(args=[], returncode=0) + with ( + patch("src.process_manager.compare_mtime", return_value=True), + patch("src.process_manager.subprocess.run", return_value=completed), + patch.object(manager, "stop_photon") as stop, + patch.object(manager, "start_photon", return_value=True) as start, + patch("src.process_manager.cleanup_backup_after_verification") as cleanup, + ): + manager.run_update() + stop.assert_called_once() + start.assert_called_once() + cleanup.assert_called_once() + + +def test_run_update_sequential_restarts_photon_after_failed_update( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + completed = subprocess.CompletedProcess(args=[], returncode=1) + manager.photon_process = None + with ( + patch("src.process_manager.compare_mtime", return_value=True), + patch("src.process_manager.subprocess.run", return_value=completed), + patch.object(manager, "stop_photon"), + patch.object(manager, "start_photon", return_value=True) as start, + ): + manager.run_update() + start.assert_called_once() + + +@pytest.mark.parametrize(("interval", "expected_unit"), [("3d", "days"), ("12h", "hours"), ("30m", "minutes")]) +def test_schedule_updates_parses_intervals( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, interval: str, expected_unit: str +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + monkeypatch.setattr(config, "UPDATE_INTERVAL", interval) + monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None)) + manager.schedule_updates() + jobs = schedule.get_jobs() + assert len(jobs) == 1 + assert jobs[0].unit == expected_unit + + +def test_schedule_updates_falls_back_to_daily_on_invalid_interval( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + monkeypatch.setattr(config, "UPDATE_INTERVAL", "garbage") + monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None)) + manager.schedule_updates() + jobs = schedule.get_jobs() + assert len(jobs) == 1 + assert jobs[0].unit == "days" + + +def test_schedule_updates_skipped_when_disabled( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "DISABLED") + monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None)) + manager.schedule_updates() + assert schedule.get_jobs() == [] + + +def test_monitor_photon_restarts_on_unexpected_exit(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.poll.side_effect = [1, None] + manager.photon_process = fake_proc + manager.state = process_manager.AppState.RUNNING + + call_count = {"n": 0} + + def restart(): + call_count["n"] += 1 + manager.should_exit = True + return True + + with patch.object(manager, "start_photon", side_effect=restart): + manager.monitor_photon() + assert call_count["n"] == 1 + + +def test_monitor_photon_logs_failed_restart(manager: process_manager.PhotonManager): + fake_proc = MagicMock() + fake_proc.poll.return_value = 1 + manager.photon_process = fake_proc + manager.state = process_manager.AppState.RUNNING + + def restart(): + manager.should_exit = True + return False + + with patch.object(manager, "start_photon", side_effect=restart): + manager.monitor_photon() + + +def test_shutdown_calls_stop_and_exits(manager: process_manager.PhotonManager): + with patch.object(manager, "stop_photon") as stop, pytest.raises(SystemExit) as exc: + manager.shutdown() + stop.assert_called_once() + assert exc.value.code == 0 + + +def test_run_skips_setup_when_index_present( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + node = tmp_path / "node_1" + node.mkdir() + monkeypatch.setattr(config, "OS_NODE_DIR", str(node)) + monkeypatch.setattr(config, "FORCE_UPDATE", False) + with ( + patch.object(manager, "run_initial_setup") as setup, + patch.object(manager, "start_photon", return_value=True), + patch.object(manager, "schedule_updates"), + patch.object(manager, "monitor_photon"), + ): + manager.run() + setup.assert_not_called() + + +def test_run_invokes_initial_setup_when_no_index( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + monkeypatch.setattr(config, "OS_NODE_DIR", str(tmp_path / "missing")) + monkeypatch.setattr(config, "FORCE_UPDATE", False) + with ( + patch.object(manager, "run_initial_setup") as setup, + patch.object(manager, "start_photon", return_value=True), + patch.object(manager, "schedule_updates"), + patch.object(manager, "monitor_photon"), + ): + manager.run() + setup.assert_called_once() + + +def test_run_exits_when_photon_fails_to_start( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + node = tmp_path / "node_1" + node.mkdir() + monkeypatch.setattr(config, "OS_NODE_DIR", str(node)) + monkeypatch.setattr(config, "FORCE_UPDATE", False) + with patch.object(manager, "start_photon", return_value=False), pytest.raises(SystemExit) as exc: + manager.run() + assert exc.value.code == 1 diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 00000000..2e6794db --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +import pytest + +from src import updater +from src.utils import config + + +def test_updater_main_runs_parallel(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "PARALLEL") + with ( + patch("src.updater.parallel_update") as parallel, + patch("src.updater.sequential_update") as sequential, + patch("src.updater.send_notification") as notify, + ): + updater.main() + parallel.assert_called_once_with() + sequential.assert_not_called() + notify.assert_called_once_with("Photon Index Updated Successfully") + + +def test_updater_main_runs_sequential(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") + with ( + patch("src.updater.parallel_update") as parallel, + patch("src.updater.sequential_update") as sequential, + patch("src.updater.send_notification"), + ): + updater.main() + parallel.assert_not_called() + sequential.assert_called_once_with() + + +def test_updater_main_exits_on_unknown_strategy(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "BOGUS") + with ( + patch("src.updater.parallel_update"), + patch("src.updater.sequential_update"), + patch("src.updater.send_notification"), + pytest.raises(SystemExit) as exc, + ): + updater.main() + assert exc.value.code == 1 + + +def test_updater_main_notifies_on_failure(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "UPDATE_STRATEGY", "PARALLEL") + with ( + patch("src.updater.parallel_update", side_effect=RuntimeError("boom")), + patch("src.updater.send_notification") as notify, + pytest.raises(SystemExit) as exc, + ): + updater.main() + assert exc.value.code == 1 + + args = [call.args[0] for call in notify.call_args_list] + assert any("Photon Update Failed" in a for a in args) diff --git a/tests/utils/test_logger.py b/tests/utils/test_logger.py new file mode 100644 index 00000000..37302988 --- /dev/null +++ b/tests/utils/test_logger.py @@ -0,0 +1,71 @@ +import contextlib +import logging +from pathlib import Path + +import pytest + +from src.utils import config, logger + + +@contextlib.contextmanager +def _empty_root_handlers(): + root = logging.getLogger() + saved = root.handlers[:] + saved_level = root.level + root.handlers = [] + try: + yield root + finally: + root.handlers = saved + root.level = saved_level + + +def test_setup_logging_attaches_console_and_file_handlers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) + monkeypatch.setattr(config, "LOG_LEVEL", "DEBUG") + + with _empty_root_handlers() as root: + logger.setup_logging() + handler_types = {type(h).__name__ for h in root.handlers} + assert "StreamHandler" in handler_types + assert "RotatingFileHandler" in handler_types + + assert (tmp_path / "logs" / "photon.log").exists() + + +def test_setup_logging_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) + monkeypatch.setattr(config, "LOG_LEVEL", "INFO") + + with _empty_root_handlers() as root: + logger.setup_logging() + first_count = len(root.handlers) + logger.setup_logging() + assert len(root.handlers) == first_count + + +def test_setup_logging_swallows_oserror_on_log_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) + monkeypatch.setattr(config, "LOG_LEVEL", "INFO") + + real_mkdir = Path.mkdir + + def fake_mkdir(self, *args, **kwargs): + if self.name == "logs": + raise PermissionError("nope") + return real_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(Path, "mkdir", fake_mkdir) + with _empty_root_handlers() as root: + logger.setup_logging() + handler_types = {type(h).__name__ for h in root.handlers} + assert "StreamHandler" in handler_types + assert "RotatingFileHandler" not in handler_types + + +def test_get_logger_returns_root_when_no_name(): + assert logger.get_logger() is logging.getLogger() + + +def test_get_logger_returns_named_logger(): + assert logger.get_logger("foo").name == "foo" diff --git a/tests/utils/test_notify.py b/tests/utils/test_notify.py new file mode 100644 index 00000000..db754255 --- /dev/null +++ b/tests/utils/test_notify.py @@ -0,0 +1,65 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src.utils import config, notify + + +class _FakeApprise: + def __init__(self, valid_count=1, notify_result=True): + self._added = [] + self._valid_count = valid_count + self._notify_result = notify_result + self.notify = MagicMock(return_value=notify_result) + + def add(self, url): + self._added.append(url) + + def __len__(self): + return self._valid_count + + +def test_send_notification_skips_when_no_urls(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture): + import logging as _logging + + monkeypatch.setattr(config, "APPRISE_URLS", "") + caplog.set_level(_logging.INFO, logger="root") + with patch("src.utils.notify.apprise.Apprise") as factory: + notify.send_notification("hello") + factory.assert_not_called() + assert any("skipping notification" in r.message for r in caplog.records) + + +def test_send_notification_sends_to_each_url(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "APPRISE_URLS", "tgram://abc, slack://xyz , ") + fake = _FakeApprise(valid_count=2) + with patch("src.utils.notify.apprise.Apprise", return_value=fake): + notify.send_notification("hello", title="Title") + + assert fake._added == ["tgram://abc", "slack://xyz"] + fake.notify.assert_called_once_with(body="hello", title="Title") + + +def test_send_notification_warns_when_all_invalid(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture): + import logging as _logging + + monkeypatch.setattr(config, "APPRISE_URLS", "garbage") + fake = _FakeApprise(valid_count=0) + caplog.set_level(_logging.WARNING, logger="root") + with patch("src.utils.notify.apprise.Apprise", return_value=fake): + notify.send_notification("hello") + assert any("No valid Apprise URLs" in r.message for r in caplog.records) + fake.notify.assert_not_called() + + +def test_send_notification_logs_error_when_apprise_fails( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + import logging as _logging + + monkeypatch.setattr(config, "APPRISE_URLS", "tgram://abc") + fake = _FakeApprise(valid_count=1, notify_result=False) + caplog.set_level(_logging.ERROR, logger="root") + with patch("src.utils.notify.apprise.Apprise", return_value=fake): + notify.send_notification("hello") + assert any("Failed to send notification" in r.message for r in caplog.records)