cc-os/plugins/os-doc-hygiene/tests/test_state_store.py

515 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Tests for scripts/state_store.py
Covers tasks 3.13.8 of the add-deterministic-core change.
sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import state_store directly.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from state_store import ( # noqa: E402
StateStore,
resolve_project_root,
)
# ---------------------------------------------------------------------------
# Fake filesystem for root-resolution tests (no real disk needed)
# ---------------------------------------------------------------------------
class FakeFS:
"""
Simulates a directory tree for resolve_project_root tests.
*git_roots* is a set of Path objects that "contain" a .git directory.
"""
def __init__(self, git_roots: set[Path]):
self._git_roots = git_roots
def is_dir(self, path: Path) -> bool:
# path is something like /project/.git or /project/sub/.git
if path.name == ".git":
return path.parent in self._git_roots
return True # every other dir "exists"
def parent(self, path: Path) -> Path:
return path.parent
# ===========================================================================
# Task 3.1 — resolve_project_root
# ===========================================================================
class TestResolveProjectRoot:
def test_git_root_found_at_start_dir(self):
"""start_dir itself contains .git → return start_dir."""
root = Path("/project")
fs = FakeFS(git_roots={root})
assert resolve_project_root(root, fs) == root
def test_git_root_found_in_ancestor(self):
"""start_dir is nested; ancestor contains .git → return ancestor."""
root = Path("/project")
nested = root / "src" / "module"
fs = FakeFS(git_roots={root})
assert resolve_project_root(nested, fs) == root
def test_fallback_to_cwd_when_no_git(self):
"""No ancestor has .git → return start_dir (cwd semantics)."""
start = Path("/no/git/here")
fs = FakeFS(git_roots=set())
result = resolve_project_root(start, fs)
assert result == start
def test_pure_no_os_getcwd_called(self, monkeypatch):
"""The pure function must never call os.getcwd() internally."""
calls = []
monkeypatch.setattr(os, "getcwd", lambda: calls.append(1) or "/spy")
root = Path("/project")
fs = FakeFS(git_roots={root})
resolve_project_root(root / "deep", fs)
assert calls == [], "resolve_project_root must not call os.getcwd()"
def test_git_root_not_intermediate_sibling(self):
"""Only ancestor .git directories count, not siblings."""
# /project has .git but we start at /other — should fall back to /other
root = Path("/project")
start = Path("/other/sub")
fs = FakeFS(git_roots={root})
assert resolve_project_root(start, fs) == start
# ===========================================================================
# Task 3.2 — StateStore confines writes; no global index; no .gitignore edits
# ===========================================================================
class TestWriteConfinement:
def test_all_writes_under_dochygiene(self, tmp_path):
"""Every path written by the store is under .dochygiene/."""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
written_paths: list[Path] = []
original_replace = os.replace
def recording_replace(src, dst):
written_paths.append(Path(dst))
return original_replace(src, dst)
with patch("os.replace", side_effect=recording_replace):
store.set_last_check()
store.set_last_reminded()
store.write_report('{"ok": true}', "# Report\nDone")
state_dir = tmp_path / ".dochygiene"
for p in written_paths:
assert str(p).startswith(str(state_dir)), (
f"Write escaped .dochygiene/: {p}"
)
def test_no_global_index_created(self, tmp_path):
"""Operating on one project must not create anything in ~/ or /tmp."""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
store.set_last_check()
store.write_report("{}", "md")
home_dh = Path.home() / ".dochygiene"
assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
def test_gitignore_never_opened_or_written(self, tmp_path):
"""
.gitignore must never be opened or written.
Strategy: pre-create .gitignore with sentinel content, run every store
operation, assert bytes are unchanged AND no write path ends in .gitignore.
"""
(tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore"
sentinel = b"# sentinel -- must not change\n"
gitignore.write_bytes(sentinel)
written_paths: list[Path] = []
original_replace = os.replace
def recording_replace(src, dst):
written_paths.append(Path(dst))
return original_replace(src, dst)
with patch("os.replace", side_effect=recording_replace):
store = StateStore(project_root=tmp_path)
store.set_last_check()
store.set_last_clean()
store.set_last_reminded()
store.write_report("{}", "md")
# Byte-identical sentinel check
assert gitignore.read_bytes() == sentinel, ".gitignore content was altered"
# No write path touches .gitignore
for p in written_paths:
assert p.name != ".gitignore", f"Write went to .gitignore: {p}"
def test_assert_confined_raises_for_out_of_root_path(self, tmp_path):
"""_assert_confined must raise ValueError for a path outside .dochygiene."""
store = StateStore(project_root=tmp_path)
with pytest.raises(ValueError, match="outside .dochygiene"):
store._assert_confined(tmp_path / "other" / "file.txt")
# ===========================================================================
# Task 3.3 — Lifecycle timestamps (via real disk; injected clock)
# ===========================================================================
class FrozenClock:
def __init__(self, dt: datetime):
self._dt = dt
def now(self) -> datetime:
return self._dt
class TestTimestamps:
def test_timestamp_round_trip(self, tmp_path):
"""Write a timestamp; read it back; values match."""
fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
store = StateStore(project_root=tmp_path, clock=FrozenClock(fixed))
store.set_last_check(fixed)
result = store.get_last_check()
assert result == fixed
def test_absent_timestamp_is_none(self, tmp_path):
"""Reading an absent timestamp returns None (not an error)."""
store = StateStore(project_root=tmp_path)
assert store.get_last_check() is None
assert store.get_last_clean() is None
assert store.get_last_reminded() is None
def test_all_three_timestamps_coexist(self, tmp_path):
"""All three timestamps can be written and read independently."""
t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
t2 = datetime(2026, 3, 1, tzinfo=timezone.utc)
t3 = datetime(2026, 6, 1, tzinfo=timezone.utc)
store = StateStore(project_root=tmp_path)
store.set_last_check(t1)
store.set_last_clean(t2)
store.set_last_reminded(t3)
assert store.get_last_check() == t1
assert store.get_last_clean() == t2
assert store.get_last_reminded() == t3
def test_clock_now_used_when_no_value_given(self, tmp_path):
"""set_timestamp without an explicit value uses the injected clock."""
fixed = datetime(2026, 6, 18, 9, 30, 0, tzinfo=timezone.utc)
clock = FrozenClock(fixed)
store = StateStore(project_root=tmp_path, clock=clock)
store.set_last_reminded() # no explicit value → clock.now()
assert store.get_last_reminded() == fixed
def test_missing_state_file_reads_as_unset(self, tmp_path):
"""If state.json does not exist, all timestamps are None."""
store = StateStore(project_root=tmp_path)
# Do not call any setter — state.json is absent.
assert store.get_timestamp("last_check") is None
def test_corrupt_state_file_reads_as_unset(self, tmp_path):
"""A corrupt state.json does not raise — timestamps read as None."""
state_dir = tmp_path / ".dochygiene"
state_dir.mkdir()
(state_dir / "state.json").write_bytes(b"not json {{")
store = StateStore(project_root=tmp_path)
assert store.get_last_check() is None
def test_invalid_timestamp_key_raises(self, tmp_path):
store = StateStore(project_root=tmp_path)
with pytest.raises(ValueError, match="Unknown timestamp key"):
store.get_timestamp("last_synced")
# ===========================================================================
# Task 3.4 — Atomic writes
# ===========================================================================
class TestAtomicWrites:
def test_temp_file_in_same_directory(self, tmp_path):
"""
The temp file must be created in the same directory as the target,
not in /tmp — os.replace is only atomic within one filesystem.
"""
store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene"
temp_paths_seen: list[Path] = []
original_replace = os.replace
def spy_replace(src, dst):
temp_paths_seen.append(Path(src))
return original_replace(src, dst)
with patch("os.replace", side_effect=spy_replace):
store.set_last_check()
assert temp_paths_seen, "os.replace was never called"
for tp in temp_paths_seen:
assert tp.parent == state_dir, (
f"Temp file {tp} is not in state_dir {state_dir}"
)
def test_torn_write_safety(self, tmp_path):
"""
Prove the atomic-write mechanism:
At the moment os.replace is called, the *source* (temp) holds the complete
new content and the *destination* holds either the complete old content or
does not exist — never a partial write.
We monkeypatch os.replace to intercept the call and check both invariants.
"""
store = StateStore(project_root=tmp_path)
state_path = tmp_path / ".dochygiene" / "state.json"
# Write an initial "old" state.
store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc))
old_bytes = state_path.read_bytes()
observed_src_content: list[bytes] = []
observed_dst_content: list[bytes | None] = []
original_replace = os.replace
def intercepting_replace(src, dst):
# Record source content (must be complete new content).
observed_src_content.append(Path(src).read_bytes())
# Record destination content (must be old-complete or non-existent).
dst_path = Path(dst)
if dst_path.exists():
observed_dst_content.append(dst_path.read_bytes())
else:
observed_dst_content.append(None)
return original_replace(src, dst)
with patch("os.replace", side_effect=intercepting_replace):
store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc))
assert observed_src_content, "os.replace was not called"
for src_bytes in observed_src_content:
# Source must be valid JSON (complete new write).
parsed = json.loads(src_bytes)
assert isinstance(parsed, dict)
for dst_bytes in observed_dst_content:
if dst_bytes is not None:
# Destination must be the old complete content.
assert dst_bytes == old_bytes, (
"Destination was not the prior complete state at replace time"
)
# None means no prior file — valid (first write).
def test_no_direct_truncate_write_to_target(self, tmp_path):
"""
The store must never open the target in 'w'/'wb' mode directly —
all writes go through the temp-then-replace path.
"""
store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene"
state_dir.mkdir(exist_ok=True)
state_target = state_dir / "state.json"
opened_paths_with_write_mode: list[str] = []
real_open = open
def spying_open(file, mode="r", *args, **kwargs):
if isinstance(mode, str) and ("w" in mode or "x" in mode):
opened_paths_with_write_mode.append(str(file))
return real_open(file, mode, *args, **kwargs)
with patch("builtins.open", side_effect=spying_open):
store.set_last_check()
for p in opened_paths_with_write_mode:
assert Path(p) != state_target, (
f"Target {state_target} was opened directly in write mode"
)
# ===========================================================================
# Task 3.5 — Report rollover
# ===========================================================================
class TestReportRollover:
def _report_files(self, tmp_path: Path) -> list[Path]:
state_dir = tmp_path / ".dochygiene"
return sorted(state_dir.glob("report.*"))
def test_first_write_creates_exactly_one_pair(self, tmp_path):
store = StateStore(project_root=tmp_path)
store.write_report('{"run": 1}', "# Run 1")
files = self._report_files(tmp_path)
names = {f.name for f in files}
assert names == {"report.json", "report.md"}
def test_second_write_replaces_first(self, tmp_path):
store = StateStore(project_root=tmp_path)
store.write_report('{"run": 1}', "# Run 1")
store.write_report('{"run": 2}', "# Run 2")
files = self._report_files(tmp_path)
names = {f.name for f in files}
assert names == {"report.json", "report.md"}, (
f"Expected exactly one pair, found: {names}"
)
json_content = json.loads((tmp_path / ".dochygiene" / "report.json").read_text())
assert json_content["run"] == 2, "Old report JSON was not replaced"
md_content = (tmp_path / ".dochygiene" / "report.md").read_text()
assert "Run 2" in md_content, "Old report MD was not replaced"
def test_many_writes_leave_exactly_one_pair(self, tmp_path):
store = StateStore(project_root=tmp_path)
for i in range(10):
store.write_report(f'{{"run": {i}}}', f"# Run {i}")
files = self._report_files(tmp_path)
names = {f.name for f in files}
assert names == {"report.json", "report.md"}
def test_rollover_does_not_delete_state_json(self, tmp_path):
"""Report rollover must NEVER delete state.json (critical trap #1)."""
store = StateStore(project_root=tmp_path)
store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))
state_before = (tmp_path / ".dochygiene" / "state.json").read_bytes()
store.write_report("{}", "md")
store.write_report("{}", "md2") # second rollover
state_after = (tmp_path / ".dochygiene" / "state.json").read_bytes()
assert state_before == state_after, (
"state.json was modified or deleted during report rollover"
)
def test_report_content_survives_rollover(self, tmp_path):
"""After a rollover the latest content is readable."""
store = StateStore(project_root=tmp_path)
store.write_report('{"v": "first"}', "# First")
store.write_report('{"v": "second"}', "# Second")
pair = store.read_report()
assert pair is not None
json_blob, md_blob = pair
assert json.loads(json_blob)["v"] == "second"
assert "Second" in md_blob
def test_read_report_returns_none_when_absent(self, tmp_path):
store = StateStore(project_root=tmp_path)
assert store.read_report() is None
# ===========================================================================
# Task 3.6 — Root resolution: write confinement (no path outside root written)
# ===========================================================================
class TestRootResolutionConfinement:
def test_writes_confined_after_git_root_resolution(self, tmp_path):
"""
Scenario: git-root case — writes are under <git-root>/.dochygiene/.
"""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
store.set_last_check()
store.write_report("{}", "md")
state_dir = tmp_path / ".dochygiene"
for child in state_dir.iterdir():
# All files live inside .dochygiene
assert child.parent == state_dir
# Nothing written outside the project root
unexpected = [
p for p in tmp_path.iterdir()
if p != state_dir and p.name != ".git"
]
assert not unexpected, f"Unexpected files outside .dochygiene: {unexpected}"
def test_writes_confined_in_cwd_fallback(self, tmp_path):
"""
Scenario: cwd-fallback case — no .git anywhere, store still confines writes.
"""
store = StateStore(project_root=tmp_path) # no .git created
store.set_last_reminded()
state_dir = tmp_path / ".dochygiene"
assert state_dir.exists()
for child in state_dir.iterdir():
assert child.parent == state_dir
def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path):
"""
Creating .dochygiene/ for the first time must not touch .gitignore.
"""
(tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore"
sentinel = b"*.pyc\n"
gitignore.write_bytes(sentinel)
store = StateStore(project_root=tmp_path)
store.set_last_check() # triggers _ensure_state_dir internally
assert gitignore.read_bytes() == sentinel
# ===========================================================================
# Fixtures under tests/fixtures/state_store/ are used as doc-tree inputs
# for integration smoke tests below.
# ===========================================================================
class TestFixtures:
"""
Smoke-test that the fixture directory exists and the store works on a
fixture-style project layout (a directory with .git).
"""
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "state_store"
def test_fixture_dir_exists(self):
assert self.FIXTURE_DIR.exists(), (
f"Fixture dir missing: {self.FIXTURE_DIR}"
)
def test_store_works_in_fixture_project(self):
fixture_proj = self.FIXTURE_DIR / "sample_project"
assert fixture_proj.exists(), (
f"sample_project fixture missing: {fixture_proj}"
)
# Use the fixture as a read-only project root; write state into a tmp copy.
import tempfile, shutil
with tempfile.TemporaryDirectory() as td:
proj = Path(td) / "proj"
shutil.copytree(fixture_proj, proj)
store = StateStore(project_root=proj)
store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc))
assert store.get_last_check() == datetime(2026, 6, 18, tzinfo=timezone.utc)