- Single exclusive lock covers read-check-write in one critical section - Lockfile pattern ensures cross-process mutual exclusion - Atomic write via temp file + rename after unlock - Flush + fsync before unlock to prevent data loss - Real multi-process test: 3 concurrent processes get exactly 1 reservation - 111 Python tests pass (+2 new tests)
144 lines
5.8 KiB
Python
144 lines
5.8 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from rf4_research import community_cli
|
|
from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch
|
|
|
|
|
|
def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
|
|
"""A02: check_and_reserve is atomic — one reservation per interval per source."""
|
|
from rf4_research.community_cli import check_and_reserve
|
|
|
|
state_file = tmp_path / "fetch-state.json"
|
|
check_and_reserve("rf4map-point", state_file=state_file, now=1_000)
|
|
|
|
# Second call for same source within interval should fail
|
|
with pytest.raises(RuntimeError, match="retry in 1800 seconds"):
|
|
check_and_reserve("rf4map-point", state_file=state_file, now=1_000)
|
|
# Different source should succeed
|
|
check_and_reserve("rf4posts-spot", state_file=state_file, now=1_000)
|
|
# After interval expires, should succeed again
|
|
check_and_reserve("rf4map-point", state_file=state_file, now=2_800)
|
|
|
|
|
|
def test_fetch_site_key_groups_endpoints_and_normalizes_www() -> None:
|
|
assert fetch_site_key("https://rf4-stat.ru/fishing/") == "rf4-stat.ru"
|
|
assert fetch_site_key("https://www.rf4-stat.ru/posts/") == "rf4-stat.ru"
|
|
|
|
|
|
def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
state_file = tmp_path / "fetch-state.json"
|
|
|
|
def fail(_url: str) -> str:
|
|
raise OSError("offline")
|
|
|
|
monkeypatch.setattr(community_cli, "fetch_html", fail)
|
|
assert community_cli.main(["rf4db", "--state-file", str(state_file)]) == 1
|
|
assert "download.rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_validate_url_host_rejects_disallowed_hosts() -> None:
|
|
from rf4_research.community_cli import _validate_url_host
|
|
|
|
# Allowed hosts pass
|
|
assert _validate_url_host("https://download.rf4db.com/ru/catches") == "download.rf4db.com"
|
|
assert _validate_url_host("https://www.rf4-stat.ru/posts/") == "rf4-stat.ru"
|
|
assert _validate_url_host("https://rf4map.ru/point/123") == "rf4map.ru"
|
|
|
|
# Disallowed hosts raise ValueError before network I/O
|
|
with pytest.raises(ValueError, match="not in allowlist"):
|
|
_validate_url_host("https://169.254.169.254/latest/meta-data/")
|
|
with pytest.raises(ValueError, match="not in allowlist"):
|
|
_validate_url_host("https://internal-service.corp/api")
|
|
# Port validation also works
|
|
with pytest.raises(ValueError, match="not in allowed ports"):
|
|
_validate_url_host("https://download.rf4db.com:9999/admin")
|
|
|
|
|
|
def test_validate_url_host_rejects_missing_hostname() -> None:
|
|
from rf4_research.community_cli import _validate_url_host
|
|
|
|
with pytest.raises(ValueError, match="valid hostname"):
|
|
_validate_url_host("https://")
|
|
with pytest.raises(ValueError, match="scheme.*not allowed"):
|
|
_validate_url_host("ftp://rf4db.com/file")
|
|
with pytest.raises(ValueError, match="HTTPS"):
|
|
_validate_url_host("http://rf4db.com/file")
|
|
|
|
|
|
def test_write_state_is_atomic_with_flush(tmp_path: Path) -> None:
|
|
"""A02: _write_state uses exclusive lock, flush, and atomic rename."""
|
|
from rf4_research.community_cli import _read_state, _write_state
|
|
|
|
state_file = tmp_path / "state.json"
|
|
_write_state(state_file, {"key1": "value1"})
|
|
assert state_file.exists()
|
|
assert not (state_file.with_suffix(".tmp")).exists()
|
|
state = _read_state(state_file)
|
|
assert state == {"key1": "value1"}
|
|
|
|
_write_state(state_file, {"key1": "value2", "key2": "value3"})
|
|
state = _read_state(state_file)
|
|
assert state == {"key1": "value2", "key2": "value3"}
|
|
assert not (state_file.with_suffix(".tmp")).exists()
|
|
|
|
|
|
def test_check_and_reserve_allows_only_one_per_interval(tmp_path: Path) -> None:
|
|
"""A02: Atomic check-and-reserve — only one success per interval."""
|
|
from rf4_research.community_cli import check_and_reserve
|
|
|
|
state_file = tmp_path / "state.json"
|
|
base_time = 1000.0
|
|
|
|
# First call should succeed
|
|
check_and_reserve("test-source", state_file=state_file, now=base_time)
|
|
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
assert state["test-source"] == base_time
|
|
|
|
# Second call within interval should fail
|
|
with pytest.raises(RuntimeError, match="retry in 1799"):
|
|
check_and_reserve("test-source", state_file=state_file, now=base_time + 1)
|
|
|
|
# After interval expires, should succeed again
|
|
check_and_reserve("test-source", state_file=state_file, now=base_time + 1800)
|
|
|
|
|
|
def _try_reserve_for_test(args: tuple) -> tuple:
|
|
"""Helper for multiprocessing — must be at module level."""
|
|
pid, state_file_str, results_list = args
|
|
from rf4_research.community_cli import check_and_reserve
|
|
from pathlib import Path
|
|
try:
|
|
check_and_reserve("shared-source", state_file=Path(state_file_str), now=1000.0)
|
|
results_list.append((pid, "ok"))
|
|
except RuntimeError as e:
|
|
results_list.append((pid, str(e)))
|
|
return (pid, "ok")
|
|
|
|
|
|
def test_check_and_reserve_atomic_under_concurrent_access(tmp_path: Path) -> None:
|
|
"""A02: Real multi-process test — concurrent processes get at most one reservation."""
|
|
import multiprocessing
|
|
|
|
state_file = tmp_path / "concurrent.json"
|
|
results = multiprocessing.Manager().list()
|
|
|
|
# Launch 3 processes simultaneously
|
|
processes = []
|
|
for i in range(3):
|
|
args = (i, str(state_file), results)
|
|
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
|
|
processes.append(p)
|
|
for p in processes:
|
|
p.start()
|
|
for p in processes:
|
|
p.join(timeout=10)
|
|
|
|
# At most one should succeed
|
|
ok_count = sum(1 for _, r in results if r == "ok")
|
|
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
|
|
denied_count = sum(1 for _, r in results if "cooldown" in r)
|
|
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
|