Expose authoritative ingest phases and supervisor-backed process liveness without semantic activity inference. Require deterministic registry, manifest, archive, file-index, and declared-output verification before an agent-successful job can finish succeeded.\n\nPreserve legacy state values, derive defaults for older job records, keep verification outside the manager lock, and retain partial outputs on verification failure.\n\nValidated with the full unittest suite (205 tests).
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
import hashlib
|
|
import json
|
|
import sqlite3
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from obsidian_wiki.mcp_ingest_verification import verify_ingest_outputs
|
|
|
|
|
|
class McpIngestVerificationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tempdir.name)
|
|
self.vault = self.root / "vault"
|
|
self.runtime = self.root / "runtime"
|
|
self.vault.mkdir()
|
|
self.runtime.mkdir()
|
|
(self.vault / "_meta").mkdir()
|
|
self.payload = b"verified source"
|
|
self.digest = hashlib.sha256(self.payload).hexdigest()
|
|
self.upload_path = self.runtime / "source.pdf"
|
|
self.upload_path.write_bytes(self.payload)
|
|
self.upload = {
|
|
"upload_id": "upload-1",
|
|
"path": str(self.upload_path),
|
|
"sha256": f"sha256:{self.digest}",
|
|
}
|
|
self.started_at = "2026-07-16T10:00:00Z"
|
|
self.archive_path = "_sources/archive/project/2026-07/source.pdf"
|
|
self.registry_record = {
|
|
"source_id": f"sha256:{self.digest}",
|
|
"archive_path": self.archive_path,
|
|
"related_pages": ["references/source.md"],
|
|
"catalog_paths": ["_sources/catalog/source.md"],
|
|
}
|
|
self.manifest_record = {
|
|
"ingested_at": "2026-07-16T10:01:00Z",
|
|
"content_hash": f"sha256:{self.digest}",
|
|
"source_id": f"sha256:{self.digest}",
|
|
"original_path": str(self.upload_path),
|
|
"archive_path": self.archive_path,
|
|
"pages_created": ["references/source.md"],
|
|
"pages_updated": [],
|
|
"catalog_paths": ["_sources/catalog/source.md"],
|
|
}
|
|
self._write_complete_outputs()
|
|
|
|
def tearDown(self):
|
|
self.tempdir.cleanup()
|
|
|
|
def _write_complete_outputs(self):
|
|
archive = self.vault / self.archive_path
|
|
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
archive.write_bytes(self.payload)
|
|
for relative_path in ("references/source.md", "_sources/catalog/source.md"):
|
|
path = self.vault / relative_path
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("verified", encoding="utf-8")
|
|
registry = self.vault / "_meta" / "source-registry.jsonl"
|
|
registry.write_text(json.dumps(self.registry_record) + "\n", encoding="utf-8")
|
|
manifest = {"version": 1, "sources": {str(self.upload_path): self.manifest_record}}
|
|
(self.vault / ".manifest.json").write_text(
|
|
json.dumps(manifest),
|
|
encoding="utf-8",
|
|
)
|
|
db_path = self.vault / "_meta" / "file-assets.sqlite"
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute("CREATE TABLE file_assets (sha256 TEXT NOT NULL)")
|
|
conn.execute("INSERT INTO file_assets (sha256) VALUES (?)", (self.digest,))
|
|
|
|
def _verify(self):
|
|
return verify_ingest_outputs(
|
|
vault_path=self.vault,
|
|
uploads=[self.upload],
|
|
job_started_at=self.started_at,
|
|
archive_sources=True,
|
|
)
|
|
|
|
def test_passes_when_all_deterministic_outputs_exist(self):
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "passed")
|
|
self.assertEqual(result["failures"], [])
|
|
self.assertEqual(
|
|
result["outputs"],
|
|
{
|
|
"source_count": 1,
|
|
"registry_count": 1,
|
|
"archive_count": 1,
|
|
"manifest_count": 1,
|
|
"indexed_count": 1,
|
|
},
|
|
)
|
|
self.assertTrue(result["checked_at"])
|
|
|
|
def test_fails_when_registry_record_is_missing(self):
|
|
(self.vault / "_meta" / "source-registry.jsonl").write_text("", encoding="utf-8")
|
|
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("registry_missing", {item["code"] for item in result["failures"]})
|
|
|
|
def test_fails_when_required_archive_is_missing(self):
|
|
(self.vault / self.archive_path).unlink()
|
|
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("archive_missing", {item["code"] for item in result["failures"]})
|
|
|
|
def test_fails_when_manifest_record_is_missing_or_stale(self):
|
|
self.manifest_record["ingested_at"] = "2026-07-16T09:59:59Z"
|
|
manifest = {"version": 1, "sources": {str(self.upload_path): self.manifest_record}}
|
|
(self.vault / ".manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("manifest_missing", {item["code"] for item in result["failures"]})
|
|
|
|
def test_fails_when_file_asset_index_row_is_missing(self):
|
|
with sqlite3.connect(self.vault / "_meta" / "file-assets.sqlite") as conn:
|
|
conn.execute("DELETE FROM file_assets")
|
|
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("index_missing", {item["code"] for item in result["failures"]})
|
|
|
|
def test_fails_when_declared_output_path_is_missing(self):
|
|
(self.vault / "references" / "source.md").unlink()
|
|
|
|
result = self._verify()
|
|
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("declared_path_missing", {item["code"] for item in result["failures"]})
|
|
|
|
def test_archive_check_is_skipped_when_archiving_is_disabled(self):
|
|
(self.vault / self.archive_path).unlink()
|
|
|
|
result = verify_ingest_outputs(
|
|
vault_path=self.vault,
|
|
uploads=[self.upload],
|
|
job_started_at=self.started_at,
|
|
archive_sources=False,
|
|
)
|
|
|
|
self.assertEqual(result["status"], "passed")
|
|
self.assertEqual(result["outputs"]["archive_count"], 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|