632 lines
25 KiB
Python
632 lines
25 KiB
Python
"""
|
||
Tests for scripts/state_store.py
|
||
|
||
Covers tasks 3.1–3.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, monkeypatch):
|
||
"""Operating on one project must not create anything in ~/ or /tmp.
|
||
|
||
HOME is redirected to an empty temp dir: asserting on the user's real
|
||
home is flaky — other tools (e.g. os-backlog's projects registry)
|
||
legitimately create ~/.cc-os outside this plugin's control.
|
||
"""
|
||
fake_home = tmp_path / "home"
|
||
fake_home.mkdir()
|
||
monkeypatch.setenv("HOME", str(fake_home))
|
||
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
(project / ".git").mkdir()
|
||
store = StateStore(project_root=project)
|
||
store.set_last_check()
|
||
store.write_report("{}", "md")
|
||
|
||
home_dh = fake_home / ".dochygiene"
|
||
home_cc_os = fake_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()
|