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

621 lines
25 KiB
Python
Raw Normal View History

"""
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 .cc-os/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 / ".cc-os" / "dochygiene"
for p in written_paths:
assert str(p).startswith(str(state_dir)), (
f"Write escaped .cc-os/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"
home_cc_os = Path.home() / ".cc-os"
assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
assert not home_cc_os.exists(), "Global ~/.cc-os 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 .cc-os/dochygiene."""
store = StateStore(project_root=tmp_path)
with pytest.raises(ValueError, match="outside .cc-os/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 / ".cc-os" / "dochygiene"
state_dir.mkdir(parents=True)
(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 / ".cc-os" / "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 / ".cc-os" / "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 / ".cc-os" / "dochygiene"
state_dir.mkdir(parents=True, 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 / ".cc-os" / "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 / ".cc-os" / "dochygiene" / "report.json").read_text())
assert json_content["run"] == 2, "Old report JSON was not replaced"
md_content = (tmp_path / ".cc-os" / "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 / ".cc-os" / "dochygiene" / "state.json").read_bytes()
store.write_report("{}", "md")
store.write_report("{}", "md2") # second rollover
state_after = (tmp_path / ".cc-os" / "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>/.cc-os/dochygiene/.
"""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
store.set_last_check()
store.write_report("{}", "md")
state_dir = tmp_path / ".cc-os" / "dochygiene"
for child in state_dir.iterdir():
# All files live inside .cc-os/dochygiene
assert child.parent == state_dir
# Nothing written outside the project root
cc_os_dir = tmp_path / ".cc-os"
unexpected = [
p for p in tmp_path.iterdir()
if p != cc_os_dir and p.name != ".git"
]
assert not unexpected, f"Unexpected files outside .cc-os/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 / ".cc-os" / "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 .cc-os/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)
# ===========================================================================
# ADR-027 — legacy .dochygiene/ fallback reads + auto-migration on first write
# ===========================================================================
class TestLegacyFallbackAndMigration:
def _write_legacy_state(self, tmp_path: Path, state: dict) -> Path:
legacy_dir = tmp_path / ".dochygiene"
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "state.json").write_text(json.dumps(state))
return legacy_dir
def _write_legacy_report(self, tmp_path: Path, json_blob: str, md_blob: str) -> Path:
legacy_dir = tmp_path / ".dochygiene"
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "report.json").write_text(json_blob)
(legacy_dir / "report.md").write_text(md_blob)
return legacy_dir
def test_reads_fall_back_to_legacy_timestamp(self, tmp_path):
"""A timestamp written only under legacy .dochygiene/ is still readable."""
fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": fixed.isoformat()})
store = StateStore(project_root=tmp_path)
assert store.get_last_check() == fixed
# Canonical dir must NOT have been created by a pure read.
assert not (tmp_path / ".cc-os").exists()
def test_reads_fall_back_to_legacy_report(self, tmp_path):
"""A report written only under legacy .dochygiene/ is still readable."""
self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")
store = StateStore(project_root=tmp_path)
pair = store.read_report()
assert pair is not None
json_blob, md_blob = pair
assert json.loads(json_blob)["v"] == "legacy"
assert "Legacy" in md_blob
assert not (tmp_path / ".cc-os").exists()
def test_canonical_state_takes_precedence_over_legacy(self, tmp_path):
"""If both canonical and legacy state exist, canonical wins."""
legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
canonical_ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})
store = StateStore(project_root=tmp_path)
store.set_last_check(canonical_ts) # triggers migration + then overwrites
assert store.get_last_check() == canonical_ts
def test_first_write_migrates_legacy_state_and_removes_legacy_dir(self, tmp_path):
"""
First write triggers migration: pre-existing legacy files are copied into
the canonical dir, and the legacy dir is removed entirely.
"""
legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})
self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")
store = StateStore(project_root=tmp_path)
store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc))
legacy_dir = tmp_path / ".dochygiene"
canonical_dir = tmp_path / ".cc-os" / "dochygiene"
assert not legacy_dir.exists(), "Legacy .dochygiene/ must be removed after migration"
assert canonical_dir.is_dir()
# Migrated last_check must be preserved alongside the newly-written last_clean.
assert store.get_last_check() == legacy_ts
assert store.get_last_clean() == datetime(2026, 6, 1, tzinfo=timezone.utc)
# Migrated report is preserved too.
pair = store.read_report()
assert pair is not None
assert json.loads(pair[0])["v"] == "legacy"
def test_migration_does_not_clobber_existing_canonical_files(self, tmp_path):
"""
If the canonical dir already has a file with the same name as a legacy
file, the canonical file wins (never overwritten by the legacy copy).
"""
canonical_dir = tmp_path / ".cc-os" / "dochygiene"
canonical_dir.mkdir(parents=True)
(canonical_dir / "state.json").write_text(
json.dumps({"last_check": "2026-06-01T00:00:00+00:00"})
)
self._write_legacy_state(tmp_path, {"last_check": "2026-01-01T00:00:00+00:00"})
store = StateStore(project_root=tmp_path)
# Any write triggers the migration pass.
store.set_last_reminded(datetime(2026, 7, 1, tzinfo=timezone.utc))
assert not (tmp_path / ".dochygiene").exists()
assert store.get_last_check() == datetime(2026, 6, 1, tzinfo=timezone.utc)
def test_new_project_with_no_legacy_dir_is_unaffected(self, tmp_path):
"""No legacy dir present → normal operation, no migration attempted."""
store = StateStore(project_root=tmp_path)
store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))
assert not (tmp_path / ".dochygiene").exists()
assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").is_file()