299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""The release gate, the read-only dry run, and the approval that unlocks mutation
|
|
(US07-07).
|
|
|
|
The gate itself is exercised with a tiny stage set — running the whole suite from
|
|
inside the suite would be a fork bomb with better manners. What is proven here is
|
|
the machinery a release depends on: the story matrix is complete, a failing stage
|
|
fails the gate, an unexpected skip fails the gate, and the evidence is written with
|
|
checksums that match what was written.
|
|
|
|
The dry run is proven to be read-only against a real temporary library, and the
|
|
approval is proven to be what stands between a configured library and any mutation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from photo_pipeline.api.app import create_app
|
|
from photo_pipeline.config import Config
|
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
|
from photo_pipeline.models import Asset
|
|
from photo_pipeline.services import release
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
# Two throwaway stages: one that passes, one the test can point at a failure.
|
|
PASSING = ("tests/e2e/test_traceability.py",)
|
|
|
|
|
|
def _config(tmp_path, **extra) -> Config:
|
|
data = tmp_path / "data"
|
|
data.mkdir(parents=True, exist_ok=True)
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir(exist_ok=True)
|
|
return Config.from_env(
|
|
{
|
|
"PHOTO_PIPELINE_DATA_DIR": str(data),
|
|
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
|
|
**extra,
|
|
}
|
|
)
|
|
|
|
|
|
# ── the story matrix ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_backlog_story_is_delivered_or_explicitly_planned():
|
|
matrix = release.story_matrix(REPO)
|
|
|
|
assert matrix["problems"] == [], "the story matrix has holes"
|
|
assert len(matrix["delivered"]) + len(matrix["planned"]) == matrix["stories"]
|
|
assert "US01-01" in matrix["delivered"] and "US07-07" in matrix["delivered"]
|
|
|
|
|
|
def test_a_story_without_tests_is_a_gate_failure(tmp_path):
|
|
"""A story file nobody covered must not pass quietly as 'no tests ran'."""
|
|
fake = tmp_path / "repo"
|
|
(fake / "delivery_backlog" / "stories").mkdir(parents=True)
|
|
(fake / "tests").mkdir()
|
|
(fake / "delivery_backlog" / "stories" / "US99-01-invented.md").write_text("# US99-01")
|
|
(fake / "tests" / "story_traceability.json").write_text(json.dumps({"stories": {}}))
|
|
|
|
matrix = release.story_matrix(fake)
|
|
|
|
assert matrix["problems"] == ["story with no tests and not planned: US99-01"]
|
|
|
|
|
|
def test_a_mapping_to_a_deleted_test_file_is_a_gate_failure(tmp_path):
|
|
fake = tmp_path / "repo"
|
|
(fake / "delivery_backlog" / "stories").mkdir(parents=True)
|
|
(fake / "tests").mkdir()
|
|
(fake / "delivery_backlog" / "stories" / "US99-01-invented.md").write_text("# US99-01")
|
|
(fake / "tests" / "story_traceability.json").write_text(
|
|
json.dumps({"stories": {"US99-01": ["tests/gone.py"]}})
|
|
)
|
|
|
|
assert release.story_matrix(fake)["problems"] == [
|
|
"mapped test file is missing — US99-01: tests/gone.py"
|
|
]
|
|
|
|
|
|
# ── the gate ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_gate_runs_its_stages_and_keeps_checksummed_evidence(tmp_path):
|
|
config = _config(tmp_path)
|
|
evidence = tmp_path / "evidence"
|
|
|
|
report = release.run_gate(config, output=evidence, stages=(("smoke", PASSING),))
|
|
|
|
assert report["ok"] is True and report["failures"] == []
|
|
assert report["stages"][0]["stage"] == "smoke" and report["stages"][0]["ok"] is True
|
|
assert report["revision"], "the evidence must say which commit it covers"
|
|
assert report["matrix"]["problems"] == []
|
|
|
|
written = json.loads((evidence / release.REPORT_NAME).read_text())
|
|
assert written["ok"] is True
|
|
assert (evidence / "logs" / "smoke.log").exists()
|
|
checksums = (evidence / release.CHECKSUMS_NAME).read_text().splitlines()
|
|
assert len(checksums) >= 2
|
|
for line in checksums:
|
|
digest, name = line.split(" ", 1)
|
|
assert release.sha256_file(evidence / name) == digest
|
|
|
|
|
|
def test_a_failing_stage_fails_the_gate(tmp_path):
|
|
config = _config(tmp_path)
|
|
failing = tmp_path / "failing_test.py"
|
|
failing.write_text("def test_no():\n assert False\n")
|
|
|
|
report = release.run_gate(
|
|
config, output=tmp_path / "evidence", stages=(("broken", (str(failing),)),)
|
|
)
|
|
|
|
assert report["ok"] is False and report["failures"] == ["broken"]
|
|
assert report["stages"][0]["returncode"] != 0
|
|
|
|
|
|
def test_an_unexpected_skip_fails_the_gate_but_an_environment_skip_does_not():
|
|
environment = release.StageResult(
|
|
"unit", [], 0, 0.1, "1 skipped", ["SKIPPED [1] x.py:1: exiftool not installed"]
|
|
)
|
|
silent = release.StageResult(
|
|
"unit", [], 0, 0.1, "1 skipped", ["SKIPPED [1] x.py:1: flaky, look later"]
|
|
)
|
|
|
|
assert release.unexpected_skips([environment]) == []
|
|
assert release.unexpected_skips([silent, environment]) == [
|
|
"SKIPPED [1] x.py:1: flaky, look later"
|
|
]
|
|
|
|
|
|
# ── the real-library dry run ─────────────────────────────────────────────────
|
|
|
|
|
|
def _library(root: Path) -> None:
|
|
(root / "album").mkdir(parents=True)
|
|
(root / "album" / "a.jpg").write_bytes(b"a" * 128)
|
|
(root / "album" / "b.png").write_bytes(b"b" * 64)
|
|
(root / "loose.JPG").write_bytes(b"c" * 32)
|
|
excluded = root / "_IGNORE" / "private"
|
|
excluded.mkdir(parents=True)
|
|
(excluded / "secret.jpg").write_bytes(b"never read")
|
|
|
|
|
|
def test_the_dry_run_describes_the_library_without_touching_it(tmp_path):
|
|
config = _config(tmp_path)
|
|
root = Path(config.library_roots[0])
|
|
_library(root)
|
|
before = {
|
|
str(p): (p.stat().st_mtime_ns, p.read_bytes()) for p in root.rglob("*") if p.is_file()
|
|
}
|
|
|
|
report = release.dry_run(config)
|
|
|
|
assert report["files"] == 3, "the excluded sentinel is not counted"
|
|
assert report["by_extension"] == {".jpg": 2, ".png": 1}
|
|
assert report["excluded_directories"] >= 1
|
|
assert report["mutation"] == "none — this pass is read-only"
|
|
assert report["checksum"]
|
|
assert not any("secret" in json.dumps(report) for _ in [0]), "excluded content never appears"
|
|
after = {
|
|
str(p): (p.stat().st_mtime_ns, p.read_bytes()) for p in root.rglob("*") if p.is_file()
|
|
}
|
|
assert after == before, "a read-only pass changed the library"
|
|
|
|
|
|
def test_the_dry_run_reconciles_against_what_the_database_already_knows(tmp_path):
|
|
config = _config(tmp_path)
|
|
root = Path(config.library_roots[0])
|
|
_library(root)
|
|
run_migrations(config.database_url)
|
|
engine = create_db_engine(config.database_url)
|
|
with create_session_factory(engine)() as session:
|
|
session.add(
|
|
Asset(
|
|
id=str(uuid.uuid4()),
|
|
original_path=str(root / "album" / "a.jpg"),
|
|
current_path=str(root / "album" / "a.jpg"),
|
|
discovered_at=NOW,
|
|
hash_version=1,
|
|
byte_size=128,
|
|
)
|
|
)
|
|
session.add(
|
|
Asset(
|
|
id=str(uuid.uuid4()),
|
|
original_path=str(root / "album" / "gone.jpg"),
|
|
current_path=str(root / "album" / "gone.jpg"),
|
|
discovered_at=NOW,
|
|
hash_version=1,
|
|
byte_size=1,
|
|
)
|
|
)
|
|
session.commit()
|
|
engine.dispose()
|
|
|
|
reconciliation = release.dry_run(config)["reconciliation"]
|
|
|
|
assert reconciliation["known_to_database"] == 2
|
|
assert reconciliation["already_registered"] == 1
|
|
assert reconciliation["new_to_the_application"] == 2
|
|
assert reconciliation["recorded_but_absent_total"] == 1
|
|
assert reconciliation["recorded_but_absent"][0].endswith("gone.jpg")
|
|
|
|
|
|
def test_a_library_root_that_is_not_there_is_refused(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_LIBRARY_ROOTS=str(tmp_path / "nowhere"))
|
|
with pytest.raises(release.ReleaseError, match="not a directory"):
|
|
release.dry_run(config)
|
|
|
|
|
|
# ── the approval ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_mutation_is_refused_until_the_dry_run_is_approved(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL="1")
|
|
_library(Path(config.library_roots[0]))
|
|
|
|
blockers = release.mutation_blockers(config)
|
|
assert [blocker["code"] for blocker in blockers] == ["dry_run_not_approved"]
|
|
|
|
record = release.approve(config, release.dry_run(config), approver="domverse")
|
|
assert record["approved_by"] == "domverse" and record["report_checksum"]
|
|
assert release.mutation_blockers(config) == []
|
|
|
|
|
|
def test_an_approval_covers_the_library_it_was_written_for(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL="1")
|
|
_library(Path(config.library_roots[0]))
|
|
release.approve(config, release.dry_run(config), approver="domverse")
|
|
|
|
other = tmp_path / "other-library"
|
|
other.mkdir()
|
|
moved = config.model_copy(update={"library_roots": (other,)})
|
|
|
|
assert [b["code"] for b in release.mutation_blockers(moved)] == ["approval_scope_mismatch"]
|
|
|
|
|
|
def test_without_the_requirement_nothing_changes(tmp_path):
|
|
config = _config(tmp_path) # the loopback development default
|
|
assert release.mutation_blockers(config) == []
|
|
|
|
|
|
def test_the_api_refuses_every_mutation_until_the_report_is_approved(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL="1")
|
|
_library(Path(config.library_roots[0]))
|
|
with TestClient(create_app(config)) as client:
|
|
# Reading stays open: an operator has to see what was found to approve it.
|
|
assert client.get("/api/v1/workflow").status_code == 200
|
|
refused = client.post("/api/v1/inventory/scan", json={})
|
|
assert refused.status_code == 403
|
|
assert refused.json()["error"]["code"] == "dry_run_not_approved"
|
|
# A backup is the one mutation a careful operator takes first.
|
|
assert client.post("/api/v1/backups", json={}).status_code == 201
|
|
|
|
release.approve(config, release.dry_run(config), approver="domverse")
|
|
assert client.post("/api/v1/inventory/scan", json={}).status_code in (200, 201, 202)
|
|
|
|
|
|
def test_approving_something_that_is_not_a_report_is_refused(tmp_path):
|
|
config = _config(tmp_path)
|
|
with pytest.raises(release.ReleaseError, match="not a dry-run report"):
|
|
release.approve(config, {"files": 3}, approver="domverse")
|
|
|
|
|
|
def test_the_cli_runs_the_dry_run_and_the_approval(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL="1")
|
|
_library(Path(config.library_roots[0]))
|
|
from photo_pipeline.__main__ import main
|
|
|
|
environment = {
|
|
"PHOTO_PIPELINE_DATA_DIR": str(config.data_dir),
|
|
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(config.library_roots[0]),
|
|
"PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL": "1",
|
|
}
|
|
previous = {key: os.environ.get(key) for key in environment}
|
|
os.environ.update(environment)
|
|
try:
|
|
report_path = tmp_path / "dry-run.json"
|
|
assert main(["dry-run", "--output", str(report_path)]) == 0
|
|
assert json.loads(report_path.read_text())["files"] == 3
|
|
assert main(["approve-dry-run", str(report_path), "--approver", "domverse"]) == 0
|
|
finally:
|
|
for key, value in previous.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
assert release.mutation_blockers(config) == []
|