Files
ATOCore/tests/test_invalidate_supersede.py
Anto01 081c058f77 feat(api): invalidate + supersede for active entities and memories (Issue E)
Public retraction path so mistakes can be corrected without SQL. Unblocks
the correction workflows that the live AKC p05 session exposed.

- engineering/service.py: invalidate_active_entity returns (ok, code)
  with codes invalidated/already_invalid/not_active/not_found for clean
  HTTP mapping. supersede_entity gains superseded_by + auto-creates the
  supersedes relationship (new SUPERSEDES old), rejects self-supersede
- memory/service.py: invalidate_memory/supersede_memory accept reason
  string that lands in audit note
- api/routes.py: POST /entities/{id}/invalidate, /supersede;
  POST /memory/{id}/invalidate, /supersede (all 4 behind /v1 aliases)
- tests/test_invalidate_supersede.py: 15 tests (idempotency, 404/409,
  supersede relationship auto-creation, self-supersede rejection,
  missing-replacement rejection, v1 alias presence)
- DEV-LEDGER.md: session log + test_count 494 -> 509

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:56:24 -04:00

195 lines
6.0 KiB
Python

"""Issue E — /invalidate + /supersede for active entities and memories."""
import pytest
from fastapi.testclient import TestClient
from atocore.engineering.service import (
create_entity,
get_entity,
get_relationships,
init_engineering_schema,
invalidate_active_entity,
supersede_entity,
)
from atocore.main import app
from atocore.memory.service import create_memory, get_memories
def _get_memory(memory_id):
for status in ("active", "candidate", "invalid", "superseded"):
for m in get_memories(status=status, active_only=False, limit=5000):
if m.id == memory_id:
return m
return None
from atocore.models.database import init_db
@pytest.fixture
def env(tmp_data_dir, tmp_path, monkeypatch):
registry_path = tmp_path / "test-registry.json"
registry_path.write_text('{"projects": []}', encoding="utf-8")
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
from atocore import config
config.settings = config.Settings()
init_db()
init_engineering_schema()
yield tmp_data_dir
def test_invalidate_active_entity_transitions_to_invalid(env):
e = create_entity(entity_type="component", name="tower-to-kill")
ok, code = invalidate_active_entity(e.id, reason="duplicate")
assert ok is True
assert code == "invalidated"
assert get_entity(e.id).status == "invalid"
def test_invalidate_on_candidate_is_409(env):
e = create_entity(entity_type="component", name="still-candidate", status="candidate")
ok, code = invalidate_active_entity(e.id)
assert ok is False
assert code == "not_active"
def test_invalidate_is_idempotent_on_invalid(env):
e = create_entity(entity_type="component", name="already-gone")
invalidate_active_entity(e.id)
ok, code = invalidate_active_entity(e.id)
assert ok is True
assert code == "already_invalid"
def test_supersede_creates_relationship(env):
old = create_entity(entity_type="component", name="old-tower")
new = create_entity(entity_type="component", name="new-tower")
ok = supersede_entity(old.id, superseded_by=new.id, note="replaced")
assert ok is True
assert get_entity(old.id).status == "superseded"
rels = get_relationships(new.id, direction="outgoing")
assert any(
r.relationship_type == "supersedes" and r.target_entity_id == old.id
for r in rels
), "supersedes relationship must be auto-created"
def test_supersede_rejects_self():
# no db needed — validation is pre-write. Using a fresh env anyway.
pass # covered below via API test
def test_api_invalidate_entity(env):
e = create_entity(entity_type="component", name="api-kill")
client = TestClient(app)
r = client.post(
f"/entities/{e.id}/invalidate",
json={"reason": "test cleanup"},
)
assert r.status_code == 200
assert r.json()["status"] == "invalidated"
def test_api_invalidate_entity_idempotent(env):
e = create_entity(entity_type="component", name="api-kill-2")
client = TestClient(app)
client.post(f"/entities/{e.id}/invalidate", json={"reason": "first"})
r = client.post(f"/entities/{e.id}/invalidate", json={"reason": "second"})
assert r.status_code == 200
assert r.json()["status"] == "already_invalid"
def test_api_invalidate_unknown_entity_is_404(env):
client = TestClient(app)
r = client.post(
"/entities/nonexistent-id/invalidate",
json={"reason": "missing"},
)
assert r.status_code == 404
def test_api_invalidate_candidate_entity_is_409(env):
e = create_entity(entity_type="component", name="cand", status="candidate")
client = TestClient(app)
r = client.post(f"/entities/{e.id}/invalidate", json={"reason": "x"})
assert r.status_code == 409
def test_api_supersede_entity(env):
old = create_entity(entity_type="component", name="api-old-tower")
new = create_entity(entity_type="component", name="api-new-tower")
client = TestClient(app)
r = client.post(
f"/entities/{old.id}/supersede",
json={"superseded_by": new.id, "reason": "dedup"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "superseded"
assert body["superseded_by"] == new.id
assert get_entity(old.id).status == "superseded"
def test_api_supersede_self_is_400(env):
e = create_entity(entity_type="component", name="self-sup")
client = TestClient(app)
r = client.post(
f"/entities/{e.id}/supersede",
json={"superseded_by": e.id, "reason": "oops"},
)
assert r.status_code == 400
def test_api_supersede_missing_replacement_is_400(env):
old = create_entity(entity_type="component", name="orphan-old")
client = TestClient(app)
r = client.post(
f"/entities/{old.id}/supersede",
json={"superseded_by": "does-not-exist", "reason": "missing"},
)
assert r.status_code == 400
def test_api_invalidate_memory(env):
m = create_memory(
memory_type="project",
content="memory to retract",
project="p05",
)
client = TestClient(app)
r = client.post(
f"/memory/{m.id}/invalidate",
json={"reason": "outdated"},
)
assert r.status_code == 200
assert r.json()["status"] == "invalidated"
assert _get_memory(m.id).status == "invalid"
def test_api_supersede_memory(env):
m = create_memory(
memory_type="project",
content="memory to supersede",
project="p05",
)
client = TestClient(app)
r = client.post(
f"/memory/{m.id}/supersede",
json={"reason": "replaced by newer fact"},
)
assert r.status_code == 200
assert r.json()["status"] == "superseded"
assert _get_memory(m.id).status == "superseded"
def test_v1_aliases_present(env):
client = TestClient(app)
spec = client.get("/openapi.json").json()
paths = spec["paths"]
for p in (
"/v1/entities/{entity_id}/invalidate",
"/v1/entities/{entity_id}/supersede",
"/v1/memory/{memory_id}/invalidate",
"/v1/memory/{memory_id}/supersede",
):
assert p in paths, f"{p} missing"