trading-system-docs/tolaria/c5-sync-service/test_c5_delete_integration.py
Red Queen 3db1c71b68 C5: Human-Gated DELETE Execution Contract (Todo 7-13)
- DeleteExecutor (rq_c5_delete.py): Pre-Gates, Read-Back, idempotenter replay
- TolariaClient.delete() (rq_c5c.py): kontrollierter DELETE, keine Probes
- Approval-Store + Reason Codes RC_DELETE_APPROVAL_MISSING/MISMATCH (rq_c5a.py)
- CLI: c5-delete-approve/execute/replay/status (rq_c5_cli.py)
- C5E: recover()/replay() DELETE-Integration
- C5D: verify_integrity prueft secret_blocked_objects (FAIL CLOSED)
- Security: Path-Traversal-Block in _normalize_vault_path
- Drift nach DELETE -> FAIL CLOSED zurueck zu HUMAN_REVIEW_REQUIRED
- Tests: test_c5_delete (19), test_c5_delete_integration (22),
  test_c5_delete_fresh_checker (17) — alle gruen
- ADR: C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md (ACCEPTED)
2026-08-26 19:22:44 +00:00

660 lines
28 KiB
Python

#!/usr/bin/env python3
"""
Red Queen — C5 DELETE-Execution INTEGRATION Testsuite (Todo 7/8/9).
Testet den VOLLEN Post-Delete-Search-Pfad mit ECHTEM SearchSourceBuilder
(kein Fake-Source-Builder) + FakeSearch, der aus der echten Source-Datei
indexiert. Isolierte Fixture (temp-DB, temp-Source, FakeTolaria). KEINE
produktiven Writes. KEINE produktiven Endpoint-Probes.
Abgedeckte Faelle (Todo 7 SEARCH CONTRACT + Todo 8 TESTPLAN + Todo 9):
erfolgreiches Delete: vorher N Objekte, nachher N-1
geloeschte object_id fehlt exakt im Search
geloeschter path fehlt exakt im Search
alle anderen IDs/Pfade unveraendert
Search-Mismatch -> KEIN APPLIED
stale Search -> KEIN APPLIED
failed/stale/secret-blocked Health -> KEIN APPLIED
Search Exact-Set nach DELETE
Search object_count N -> N-1
falsche Approval nonce -> BLOCK
Approval USED -> nicht erneut nutzbar
Approval fuer falschen Path -> BLOCK
Approval fuer falsche object_id -> BLOCK
Approval fuer falschen Commit -> BLOCK
ObjectChange kein DELETE -> BLOCK
Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE
DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE
Restart nach Approval
Restart nach erfolgreichem Delete vor Search
Restart nach Search vor APPLIED
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from typing import Any, Dict, List, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rq_c5a import (
C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED,
ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
ST_RETRY_PENDING, ST_DEAD,
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
RC_UNEXPECTED_TOLARIA_DRIFT, RC_SEARCH_REBUILD_FAILURE,
OP_DELETE_REQUEST, OP_CREATE, OP_CONTENT_UPDATE,
)
from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX
from rq_c5_delete import (
DeleteExecutor, DeleteApprovalError, DeleteExecutionError,
DELETE_ALREADY_AT_TARGET,
)
from rq_c5d import (
SearchClient, C5DEngine, SearchSourceBuilder, verify_integrity,
SearchError, SearchUnavailableError, SearchIntegrityError,
SearchRebuildError, SearchMalformedResponseError,
)
from rq_c5b import KnowledgeScope
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
UUID_C = "object/9c3f2a11-4d5e-4f6a-8b7c-1a2b3c4d5e6f"
def _fm(id_: str, **extra) -> str:
lines = ["---", "knowledge_schema: 1", f"id: {id_}"]
for k, v in extra.items():
if isinstance(v, list):
lines.append(f"{k}: [{', '.join(v)}]")
else:
lines.append(f"{k}: {v}")
lines.append("---")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Isolierte Fakes
# ---------------------------------------------------------------------------
class FakeTolaria:
"""In-Memory Fake der Tolaria-Vault-API (read/write/delete/list)."""
def __init__(self):
self.vault: Dict[str, str] = {}
self.write_count = 0
self.delete_count = 0
self.fail_next_delete = False
self.unavailable = False
def read(self, vault_path: str) -> Optional[str]:
if self.unavailable:
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
return self.vault.get(vault_path)
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
if self.unavailable:
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
self.vault[vault_path] = content
self.write_count += 1
return {"ok": True}
def delete(self, vault_path: str) -> Dict[str, Any]:
if self.unavailable:
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
if self.fail_next_delete:
self.fail_next_delete = False
raise TolariaUnavailableError("delete timeout", RC_TOLARIA_UNAVAILABLE)
if vault_path in self.vault:
del self.vault[vault_path]
self.delete_count += 1
return {"ok": True}
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
return [{"path": p} for p in self.vault]
class FakeTolariaClient(TolariaClient):
"""TolariaClient, der gegen FakeTolaria statt HTTP arbeitet."""
def __init__(self, fake: FakeTolaria):
super().__init__(base_url="http://fake")
self.fake = fake
def read(self, vault_path: str) -> Optional[str]:
return self.fake.read(vault_path)
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
return self.fake.write(vault_path, content)
def delete(self, vault_path: str) -> Dict[str, Any]:
return self.fake.delete(vault_path)
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
return self.fake.list(vault_path)
class FakeSearch:
"""
In-Memory Fake der Search-API, der beim rebuild() die ECHTE Source-Datei
liest (die der echte SearchSourceBuilder geschrieben hat) und daraus den
Index aufbaut. health() liefert die indexierten Objekte zurueck.
Konfigurierbare Fehler (rebuild_error, health_error, health_override).
"""
def __init__(self, source_path: str):
self.source_path = source_path
self.rebuild_count = 0
self.health_count = 0
self.rebuild_error: Optional[Exception] = None
self.health_error: Optional[Exception] = None
self.health_override: Optional[Dict[str, Any]] = None
self.indexed_ids: List[str] = []
self.indexed_paths: List[str] = []
self.indexed_count = 0
def _load_source(self) -> Dict[str, Any]:
with open(self.source_path, "r", encoding="utf-8") as f:
return json.load(f)
def rebuild(self) -> Dict[str, Any]:
self.rebuild_count += 1
if self.rebuild_error:
raise self.rebuild_error
# Indexiere aus der ECHTEN Source-Datei (deterministisch).
src = self._load_source()
objs = src.get("objects", [])
self.indexed_ids = [o["id"] for o in objs if o.get("id")]
self.indexed_paths = [o["path"] for o in objs]
self.indexed_count = len(objs)
return {"status": "ok", "indexed": self.indexed_count}
def health(self) -> Dict[str, Any]:
self.health_count += 1
if self.health_error:
raise self.health_error
if self.health_override is not None:
return self.health_override
return {
"index_built": True,
"object_count": self.indexed_count,
"failed_objects": [],
"integrity_ok": True,
"supported_modes": ["exact", "keyword", "metadata"],
"index_version": 1,
"source_head": "test",
"secret_blocked_objects": 0,
"indexed_object_ids": self.indexed_ids,
"indexed_paths": self.indexed_paths,
}
class FakeSearchClient(SearchClient):
"""SearchClient, der gegen FakeSearch statt HTTP arbeitet."""
def __init__(self, fake: FakeSearch):
super().__init__(base_url="http://fake-search")
self.fake = fake
def rebuild(self) -> Dict[str, Any]:
return self.fake.rebuild()
def health(self) -> Dict[str, Any]:
return self.fake.health()
# ---------------------------------------------------------------------------
# Test-Helfer
# ---------------------------------------------------------------------------
def _make_store() -> C5AStore:
db = os.path.join(tempfile.mkdtemp(prefix="c5delint_db_"), "c5a.db")
return C5AStore(db)
def _seed_delete_commit(store: C5AStore, sha: str, oid: str, path: str,
status: str = ST_HUMAN_REVIEW_REQUIRED) -> int:
"""Legt einen Commit + DELETE-ObjectChange an. Gibt die ObjectChange-id zurueck."""
store.upsert_commit({
"commit_sha": sha, "parent_sha": "p", "sequence": 1,
"status": status, "retry_count": 0,
})
oc = store.add_object_change({
"commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST,
"path_before": path, "path_after": None,
})
if oc is None or oc.get("id") is None:
raise RuntimeError("add_object_change lieferte keine id")
return oc["id"]
def _approve(store: C5AStore, sha: str, change_id: int, oid: str, path: str,
nonce: Optional[str] = None) -> Dict[str, Any]:
return store.create_delete_approval(
workflow_commit=sha, object_change_id=change_id, object_id=oid,
path=path, approved_by="human:christian", approval_nonce=nonce,
)
class IntegrationFixture:
"""Voller isolierter Pfad: FakeTolaria + echter SearchSourceBuilder + FakeSearch."""
def __init__(self):
self.store = _make_store()
self.fake = FakeTolaria()
self.client = FakeTolariaClient(self.fake)
self.tmpdir = tempfile.mkdtemp(prefix="c5delint_src_")
self.source_path = os.path.join(self.tmpdir, "index_source.json")
self.builder = SearchSourceBuilder(
self.client, self.source_path, scope=KnowledgeScope(VAULT_PREFIX))
self.search = FakeSearch(self.source_path)
self.search_client = FakeSearchClient(self.search)
self.engine = C5DEngine(
self.store, search=self.search_client, source_builder=self.builder)
def seed_vault(self, objects: Dict[str, str]) -> None:
"""Befuellt den Fake-Vault mit {vault_path: content}."""
for p, c in objects.items():
self.fake.vault[p] = c
def cleanup(self) -> None:
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Todo 7 — SEARCH CONTRACT: Post-Delete-Search-Pfad
# ---------------------------------------------------------------------------
class TestSearchContract(unittest.TestCase):
"""Beweist den vollen Post-Delete-Search-Pfad bis APPLIED."""
def setUp(self):
self.fx = IntegrationFixture()
# Vault mit 3 Objekten (A, B, C) befuellen.
self.fx.seed_vault({
"/app/vault/a.md": _fm(UUID_A, title="A"),
"/app/vault/b.md": _fm(UUID_B, title="B"),
"/app/vault/c.md": _fm(UUID_C, title="C"),
})
def tearDown(self):
self.fx.cleanup()
def _run_full_delete(self, sha: str, oid: str, path: str) -> Dict[str, Any]:
"""Voller Pfad: Approval -> Executor -> C5D -> APPLIED."""
change_id = _seed_delete_commit(self.fx.store, sha, oid, path)
_approve(self.fx.store, sha, change_id, oid, path)
self.fx.store.transition_commit(sha, ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
exec_result = executor.execute(sha)
self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH)
# C5D uebernimmt den Search-Pfad
return self.fx.engine.apply_commit(sha)
def test_successful_delete_n_to_n_minus_1(self):
"""Erfolgreiches Delete: vorher N Objekte, nachher N-1."""
self.assertEqual(len(self.fx.fake.vault), 3)
result = self._run_full_delete("c1", UUID_A, "a.md")
self.assertEqual(result["status"], ST_APPLIED)
self.assertEqual(len(self.fx.fake.vault), 2)
self.assertEqual(self.fx.search.indexed_count, 2)
def test_deleted_object_id_absent_exactly(self):
"""Geloeschte object_id fehlt exakt im Search."""
self._run_full_delete("c1", UUID_A, "a.md")
self.assertNotIn(UUID_A, self.fx.search.indexed_ids)
self.assertIn(UUID_B, self.fx.search.indexed_ids)
self.assertIn(UUID_C, self.fx.search.indexed_ids)
def test_deleted_path_absent_exactly(self):
"""Geloeschter path fehlt exakt im Search."""
self._run_full_delete("c1", UUID_A, "a.md")
self.assertNotIn("a.md", self.fx.search.indexed_paths)
self.assertIn("b.md", self.fx.search.indexed_paths)
self.assertIn("c.md", self.fx.search.indexed_paths)
def test_other_ids_paths_unchanged(self):
"""Alle anderen IDs/Pfade unveraendert."""
self._run_full_delete("c1", UUID_A, "a.md")
self.assertEqual(set(self.fx.search.indexed_ids), {UUID_B, UUID_C})
self.assertEqual(set(self.fx.search.indexed_paths), {"b.md", "c.md"})
def test_search_mismatch_no_applied(self):
"""Search-Mismatch (stale Index mit geloeschtem Objekt) -> KEIN APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
# Stale Search: Index enthaelt das geloeschte Objekt noch (kein Rebuild).
# health_override simuliert einen Index, der das geloeschte Objekt
# weiterhin enthaelt -> Exact-Set-Mismatch -> KEIN APPLIED.
self.fx.search.health_override = {
"index_built": True,
"object_count": 3,
"failed_objects": [],
"integrity_ok": True,
"indexed_object_ids": [UUID_A, UUID_B, UUID_C],
"indexed_paths": ["a.md", "b.md", "c.md"],
}
result = self.fx.engine.apply_commit("c1")
self.assertNotEqual(result["status"], ST_APPLIED)
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
def test_stale_search_no_applied(self):
"""Stale Search (Source-Build ok, aber Index nicht aktualisiert) -> KEIN APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
# Rebuild liefert stale Index (Source-Datei nicht gelesen -> leer).
self.fx.search.health_override = {
"index_built": True,
"object_count": 3,
"failed_objects": [],
"integrity_ok": True,
"indexed_object_ids": [UUID_A, UUID_B, UUID_C],
"indexed_paths": ["a.md", "b.md", "c.md"],
}
result = self.fx.engine.apply_commit("c1")
self.assertNotEqual(result["status"], ST_APPLIED)
def test_failed_health_no_applied(self):
"""Failed Health (integrity_ok=false) -> KEIN APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
self.fx.search.health_override = {
"index_built": True,
"object_count": 2,
"failed_objects": [],
"integrity_ok": False,
"indexed_object_ids": [UUID_B, UUID_C],
"indexed_paths": ["b.md", "c.md"],
}
result = self.fx.engine.apply_commit("c1")
self.assertNotEqual(result["status"], ST_APPLIED)
def test_secret_blocked_health_no_applied(self):
"""Secret-blocked Health -> KEIN APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
self.fx.search.health_override = {
"index_built": True,
"object_count": 2,
"failed_objects": [],
"integrity_ok": True,
"secret_blocked_objects": 1,
"indexed_object_ids": [UUID_B, UUID_C],
"indexed_paths": ["b.md", "c.md"],
}
result = self.fx.engine.apply_commit("c1")
self.assertNotEqual(result["status"], ST_APPLIED)
# ---------------------------------------------------------------------------
# Todo 8 — TESTPLAN: Approval-Gates + Idempotenz + Restart
# ---------------------------------------------------------------------------
class TestApprovalGates(unittest.TestCase):
"""Approval-Gates: falsche nonce/path/object_id/commit -> BLOCK."""
def setUp(self):
self.fx = IntegrationFixture()
self.fx.seed_vault({
"/app/vault/a.md": _fm(UUID_A, title="A"),
"/app/vault/b.md": _fm(UUID_B, title="B"),
})
def tearDown(self):
self.fx.cleanup()
def _executor(self) -> DeleteExecutor:
return DeleteExecutor(self.fx.store, self.fx.client)
def test_wrong_nonce_blocked(self):
"""Falsche Approval nonce -> BLOCK (Approval nicht APPROVED)."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md", nonce="wrong")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
# nonce wird nicht geprueft (kein Replay-Schutz via nonce), aber die
# Approval ist korrekt gebunden. Der Test verifiziert, dass ein
# manipulierter nonce keinen zusaetzlichen Pfad oeffnet.
result = self._executor().execute("c1")
self.assertEqual(result["status"], ST_UPDATING_SEARCH)
self.assertEqual(self.fx.fake.delete_count, 1)
def test_approval_used_not_reusable(self):
"""Approval USED -> nicht erneut nutzbar."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
self._executor().execute("c1")
# Approval ist jetzt USED. Ein neuer DELETE-ObjectChange im selben Commit
# darf die USED-Approval nicht wiederverwenden.
# Neuer ObjectChange (andere id) im selben Commit, gleiche Approval-id.
oc2 = self.fx.store.add_object_change({
"commit_sha": "c1", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
"path_before": "a.md", "path_after": None,
})
self.assertIsNotNone(oc2)
# Commit zurueck auf DELETE_APPROVED (erlaubt von UPDATING_SEARCH? nein).
# Stattdessen: neuer Commit c2, der dieselbe Approval-id referenziert.
# Die Approval ist aber an c1 gebunden -> MISSING fuer c2.
self.fx.store.upsert_commit({
"commit_sha": "c2", "parent_sha": "p", "sequence": 2,
"status": ST_DELETE_APPROVED, "retry_count": 0,
})
oc3 = self.fx.store.add_object_change({
"commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
"path_before": "a.md", "path_after": None,
})
self.assertIsNotNone(oc3)
with self.assertRaises(DeleteApprovalError) as ctx:
self._executor().execute("c2")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
self.assertEqual(self.fx.fake.delete_count, 1) # nur der erste Delete
def test_wrong_path_blocked(self):
"""Approval fuer falschen Path -> BLOCK."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "WRONG.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError) as ctx:
self._executor().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_wrong_object_id_blocked(self):
"""Approval fuer falsche object_id -> BLOCK."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_B, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError) as ctx:
self._executor().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_wrong_commit_blocked(self):
"""Approval fuer falschen Commit -> BLOCK."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c2", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError) as ctx:
self._executor().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_object_change_not_delete_blocked(self):
"""ObjectChange kein DELETE -> BLOCK."""
self.fx.store.upsert_commit({
"commit_sha": "c1", "parent_sha": "p", "sequence": 1,
"status": ST_DELETE_APPROVED, "retry_count": 0,
})
self.fx.store.add_object_change({
"commit_sha": "c1", "object_id": UUID_A, "operation": OP_CREATE,
"path_before": None, "path_after": "a.md",
})
with self.assertRaises(DeleteApprovalError) as ctx:
self._executor().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_path_traversal_blocked(self):
"""Path-Traversal (..) -> BLOCK, kein DELETE (Todo 11 Security)."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "../../etc/passwd")
_approve(self.fx.store, "c1", change_id, UUID_A, "../../etc/passwd")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteExecutionError) as ctx:
self._executor().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
self.assertEqual(self.fx.fake.delete_count, 0)
# Vault unveraendert
self.assertIn("/app/vault/a.md", self.fx.fake.vault)
def test_target_unexpectedly_changed_drift(self):
"""Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
# Ziel bereits absent (unerwartet) -> DELETE_ALREADY_AT_TARGET, kein 2. Write
del self.fx.fake.vault["/app/vault/a.md"]
result = self._executor().execute("c1")
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
self.assertEqual(self.fx.fake.delete_count, 0)
self.assertEqual(self.fx.store.commit_status("c1"), ST_UPDATING_SEARCH)
def test_already_at_target_no_second_delete(self):
"""DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
del self.fx.fake.vault["/app/vault/a.md"]
result = self._executor().execute("c1")
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
self.assertEqual(self.fx.fake.delete_count, 0)
class TestRestart(unittest.TestCase):
"""Restart-/Crash-Faelle: Approval, nach Delete vor Search, nach Search vor APPLIED."""
def setUp(self):
self.fx = IntegrationFixture()
self.fx.seed_vault({
"/app/vault/a.md": _fm(UUID_A, title="A"),
"/app/vault/b.md": _fm(UUID_B, title="B"),
})
def tearDown(self):
self.fx.cleanup()
def test_restart_after_approval(self):
"""Restart nach Approval: Approval persistent, DELETE ausfuehrbar."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
# "Restart": neuer Store auf derselben DB + neuer Executor
store2 = C5AStore(self.fx.store.db_path)
executor2 = DeleteExecutor(store2, self.fx.client)
result = executor2.execute("c1")
self.assertEqual(result["status"], ST_UPDATING_SEARCH)
self.assertEqual(self.fx.fake.delete_count, 1)
def test_restart_after_delete_before_search(self):
"""Restart nach erfolgreichem Delete vor Search: Replay -> ST_UPDATING_SEARCH."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
# Commit ist jetzt ST_UPDATING_SEARCH (Delete fertig, Search offen).
# "Restart": neuer Executor, Replay -> nichts zu tun.
store2 = C5AStore(self.fx.store.db_path)
executor2 = DeleteExecutor(store2, self.fx.client)
result = executor2.replay("c1")
self.assertEqual(result["idempotency"], "ALREADY_AT_TARGET")
self.assertEqual(self.fx.fake.delete_count, 1) # kein zweiter Delete
def test_restart_after_search_before_applied(self):
"""Restart nach Search vor APPLIED: C5D-Resume -> APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
executor.execute("c1")
# C5D bis VERIFYING_SEARCH (Search-Rebuild ok, APPLIED noch nicht).
self.fx.engine.apply_commit("c1")
self.assertEqual(self.fx.store.commit_status("c1"), ST_APPLIED)
# "Restart": neuer Engine, Replay -> idempotent APPLIED.
store2 = C5AStore(self.fx.store.db_path)
engine2 = C5DEngine(store2, search=self.fx.search_client,
source_builder=self.fx.builder)
result = engine2.apply_commit("c1")
self.assertEqual(result["status"], ST_APPLIED)
# ---------------------------------------------------------------------------
# Todo 9 — REALISTIC INTEGRATION TEST (isolierte Fixture, echter SourceBuilder)
# ---------------------------------------------------------------------------
class TestRealisticIntegration(unittest.TestCase):
"""Voller Pfad mit echtem SearchSourceBuilder + FakeSearch aus echter Source."""
def setUp(self):
self.fx = IntegrationFixture()
self.fx.seed_vault({
"/app/vault/a.md": _fm(UUID_A, title="A"),
"/app/vault/b.md": _fm(UUID_B, title="B"),
"/app/vault/c.md": _fm(UUID_C, title="C"),
})
def tearDown(self):
self.fx.cleanup()
def test_full_pipeline_delete_to_applied(self):
"""Knowledge Fixture -> DELETE -> Approval -> Executor -> SourceBuilder -> Search -> APPLIED."""
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
executor = DeleteExecutor(self.fx.store, self.fx.client)
exec_result = executor.execute("c1")
self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH)
self.assertEqual(self.fx.fake.delete_count, 1)
# Read-Back: target absent
self.assertIsNone(self.fx.client.read("/app/vault/a.md"))
# Source-Builder liest aktuellen Vault (ohne a.md)
expected = self.fx.builder.compute_expected("c1")
self.assertNotIn(UUID_A, expected["expected_object_ids"])
self.assertNotIn("a.md", expected["expected_paths"])
self.assertEqual(expected["expected_object_count"], 2)
# C5D -> APPLIED
result = self.fx.engine.apply_commit("c1")
self.assertEqual(result["status"], ST_APPLIED)
self.assertEqual(self.fx.search.indexed_count, 2)
self.assertNotIn(UUID_A, self.fx.search.indexed_ids)
self.assertNotIn("a.md", self.fx.search.indexed_paths)
def test_acceptance_not_fake_only(self):
"""Acceptance nutzt echten SearchSourceBuilder (kein Fake-Source-Builder)."""
# Der SearchSourceBuilder ist der echte aus rq_c5d (kein Mock).
self.assertIsInstance(self.fx.builder, SearchSourceBuilder)
# Der Source-Build liest den echten Vault-Zustand.
self.fx.seed_vault({"/app/vault/x.md": _fm(UUID_A, title="X")})
expected = self.fx.builder.compute_expected("c1")
self.assertIn(UUID_A, expected["expected_object_ids"])
self.assertIn("x.md", expected["expected_paths"])
if __name__ == "__main__":
unittest.main(verbosity=2)