Files
2026-10-02 15:29:35 +02:00

278 lines
8.4 KiB
Python

"""Tests for the cache manager.
The downloader is monkeypatched, so no test touches the network. Atomic
replacement, freshness handling, corruption recovery, and stale fallback are
all covered.
"""
from __future__ import annotations
import gzip
import json
import os
import time
from pathlib import Path
import pytest
from sjaur.cache import CacheManager, download_file, humanize_delta
from sjaur.errors import CacheCorruptError, DownloadError, SjaurError
DATA_V1 = [{"Name": "firefox"}, {"Name": "python"}]
DATA_V2 = [{"Name": "firefox"}, {"Name": "python"}, {"Name": "neovim"}]
def gzipped(data) -> bytes:
return gzip.compress(json.dumps(data).encode())
def install_downloader(monkeypatch, data) -> None:
"""Make ``download_file`` produce a valid dump instead of using network."""
def fake_download(url, dest, progress=None, timeout=None):
dest.write_bytes(gzipped(data))
monkeypatch.setattr("sjaur.cache.download_file", fake_download)
def install_failing_downloader(monkeypatch, message="Connection timed out.") -> None:
def fake_download(url, dest, progress=None, timeout=None):
raise DownloadError(message)
monkeypatch.setattr("sjaur.cache.download_file", fake_download)
def write_cache_file(cache: CacheManager, content: bytes) -> None:
cache.cache_dir.mkdir(parents=True, exist_ok=True)
cache.metadata_path.write_bytes(content)
def make_cache_stale(cache: CacheManager, hours: float = 25.0) -> None:
old = time.time() - hours * 3600
os.utime(cache.metadata_path, (old, old))
def no_tmp_files(cache: CacheManager) -> bool:
return list(cache.cache_dir.glob("*.tmp")) == []
# -- fresh/missing/expired ----------------------------------------------------
def test_missing_cache_is_downloaded(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
install_downloader(monkeypatch, DATA_V1)
bundle = cache.ensure_fresh()
assert bundle.source == "downloaded"
assert bundle.data == DATA_V1
assert bundle.warning is None
assert cache.metadata_path.is_file()
assert no_tmp_files(cache)
def test_fresh_cache_is_used_without_download(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
def boom(*args, **kwargs):
raise AssertionError("download must not run for a fresh cache")
monkeypatch.setattr("sjaur.cache.download_file", boom)
bundle = cache.ensure_fresh()
assert bundle.source == "cache"
assert bundle.data == DATA_V1
assert cache.cache_age() is not None and cache.cache_age().total_seconds() < 60
def test_expired_cache_is_replaced(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
make_cache_stale(cache)
install_downloader(monkeypatch, DATA_V2)
bundle = cache.ensure_fresh()
assert bundle.source == "downloaded"
assert bundle.data == DATA_V2
assert json.loads(cache.metadata_path.read_text()) == DATA_V2
assert cache.cache_age().total_seconds() < 60
assert no_tmp_files(cache)
def test_force_bypasses_freshness(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
install_downloader(monkeypatch, DATA_V2)
bundle = cache.ensure_fresh(force=True)
assert bundle.source == "downloaded"
assert bundle.data == DATA_V2
# -- download failures ---------------------------------------------------------
def test_failed_download_keeps_stale_cache(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
make_cache_stale(cache)
install_failing_downloader(monkeypatch, "Connection timed out.")
bundle = cache.ensure_fresh()
assert bundle.source == "stale-cache"
assert bundle.data == DATA_V1
assert bundle.warning is not None
assert "Connection timed out." in bundle.warning
assert "Using existing cached metadata" in bundle.warning
# Old cache untouched, no leftovers.
assert json.loads(cache.metadata_path.read_text()) == DATA_V1
assert no_tmp_files(cache)
def test_failed_download_without_cache_raises(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
install_failing_downloader(monkeypatch)
with pytest.raises(SjaurError) as excinfo:
cache.ensure_fresh()
assert "timed out" in str(excinfo.value)
assert not cache.metadata_path.exists()
def test_forced_failed_download_falls_back_to_fresh_cache(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
install_failing_downloader(monkeypatch)
bundle = cache.ensure_fresh(force=True)
assert bundle.source == "stale-cache"
assert bundle.data == DATA_V1
# -- corruption handling --------------------------------------------------------
def test_corrupt_gzip_download_is_rejected_without_touching_cache(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps(DATA_V1).encode())
make_cache_stale(cache)
def corrupt_download(url, dest, progress=None, timeout=None):
dest.write_bytes(b"this is not gzip data")
monkeypatch.setattr("sjaur.cache.download_file", corrupt_download)
bundle = cache.ensure_fresh()
assert bundle.source == "stale-cache"
assert bundle.data == DATA_V1
assert json.loads(cache.metadata_path.read_text()) == DATA_V1
assert no_tmp_files(cache)
def test_fresh_but_corrupt_cache_is_replaced(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, b"{not valid json")
install_downloader(monkeypatch, DATA_V2)
bundle = cache.ensure_fresh()
assert bundle.source == "downloaded"
assert bundle.data == DATA_V2
def test_fresh_but_corrupt_cache_and_failed_download_raises(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, b"{not valid json")
install_failing_downloader(monkeypatch)
with pytest.raises(SjaurError):
cache.ensure_fresh()
def test_structurally_wrong_cache_is_rejected(tmp_path):
cache = CacheManager(tmp_path / "cache")
write_cache_file(cache, json.dumps({"not": "a list"}).encode())
with pytest.raises(CacheCorruptError):
cache._load()
def test_empty_dump_is_valid_but_yields_no_packages(tmp_path, monkeypatch):
cache = CacheManager(tmp_path / "cache")
install_downloader(monkeypatch, [])
bundle = cache.ensure_fresh()
assert bundle.data == []
def test_unwritable_cache_dir_raises_friendly_error(tmp_path):
# A *file* where the cache directory should be triggers a mkdir failure.
blocker = tmp_path / "blocker"
blocker.write_text("I am a file, not a directory")
cache = CacheManager(blocker)
with pytest.raises(SjaurError) as excinfo:
cache.ensure_fresh()
assert "cache directory" in str(excinfo.value)
# -- download_file unit tests (local file:// URL, no network) --------------------
def test_download_file_copies_content(tmp_path):
source = tmp_path / "source.gz"
source.write_bytes(gzipped(DATA_V1))
dest = tmp_path / "dest.gz"
download_file(source.as_uri(), dest)
assert dest.read_bytes() == source.read_bytes()
def test_download_file_missing_source_raises(tmp_path):
dest = tmp_path / "dest.gz"
with pytest.raises(DownloadError):
download_file((tmp_path / "missing.gz").as_uri(), dest)
def test_progress_callback_reports_bytes(tmp_path):
source = tmp_path / "source.gz"
source.write_bytes(gzipped(DATA_V1))
dest = tmp_path / "dest.gz"
seen = []
download_file(source.as_uri(), dest, progress=lambda done, total: seen.append((done, total)))
assert seen and seen[-1][0] == source.stat().st_size
# -- helpers ---------------------------------------------------------------------
def test_humanize_delta():
from datetime import timedelta
assert humanize_delta(timedelta(seconds=10)) == "10 seconds"
assert humanize_delta(timedelta(minutes=5)) == "5 minutes"
assert "hours" in humanize_delta(timedelta(hours=3))
assert "days" in humanize_delta(timedelta(days=3))
def test_cache_age_missing_file(tmp_path):
cache = CacheManager(tmp_path / "cache")
assert cache.cache_age() is None
assert not cache.is_fresh()
def test_metadata_filename(tmp_path):
cache = CacheManager(tmp_path)
assert cache.metadata_path == Path(tmp_path) / "packages-meta-ext-v1.json"