mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 21:16:36 +03:00
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""Tests for the project manifest module (projects/manifest.py).
|
|
|
|
Validates that:
|
|
- New manifests have correct defaults (UUID, name, state CREATED, timestamps).
|
|
- Manifest save is atomic (valid JSON after any write).
|
|
- Manifest load correctly reads and validates manifests.
|
|
- Loading corrupted JSON raises InvalidConfigError with exit code 4.
|
|
- Loading manifests with missing required fields raises InvalidConfigError.
|
|
- update_manifest_field atomically updates and saves.
|
|
- Timestamps are ISO 8601 with timezone.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
|
|
if str(_scripts_dir) not in sys.path:
|
|
sys.path.insert(0, str(_scripts_dir))
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from binary_analysis.domain.enums import ProjectState
|
|
from binary_analysis.domain.errors import InvalidConfigError
|
|
from binary_analysis.projects.manifest import (
|
|
_WORKSPACE_VERSION,
|
|
create_manifest,
|
|
load_manifest,
|
|
save_manifest,
|
|
update_manifest_field,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def project_dir(tmp_path: Path) -> str:
|
|
"""Fixture: a temp directory acting as a project workspace."""
|
|
return str(tmp_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create manifest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateManifest:
|
|
"""Tests for create_manifest."""
|
|
|
|
def test_defaults(self) -> None:
|
|
"""New manifest has all required fields with correct defaults."""
|
|
manifest = create_manifest("my-project")
|
|
assert "id" in manifest
|
|
assert "name" in manifest
|
|
assert "state" in manifest
|
|
assert "created_at" in manifest
|
|
assert "updated_at" in manifest
|
|
assert "workspace_version" in manifest
|
|
assert "binary_count" in manifest
|
|
assert "is_stale" in manifest
|
|
|
|
def test_state_is_created(self) -> None:
|
|
"""New manifest has state CREATED."""
|
|
manifest = create_manifest("my-project")
|
|
assert manifest["state"] == ProjectState.CREATED.value
|
|
|
|
def test_binary_count_zero(self) -> None:
|
|
"""New manifest has binary_count 0."""
|
|
manifest = create_manifest("my-project")
|
|
assert manifest["binary_count"] == 0
|
|
|
|
def test_not_stale(self) -> None:
|
|
"""New manifest has is_stale False."""
|
|
manifest = create_manifest("my-project")
|
|
assert manifest["is_stale"] is False
|
|
|
|
def test_lock_is_none(self) -> None:
|
|
"""New manifest has lock set to None."""
|
|
manifest = create_manifest("my-project")
|
|
assert manifest["lock"] is None
|
|
|
|
def test_workspace_version(self) -> None:
|
|
"""New manifest has current workspace version."""
|
|
manifest = create_manifest("my-project")
|
|
assert manifest["workspace_version"] == _WORKSPACE_VERSION
|
|
|
|
def test_id_is_valid_uuid(self) -> None:
|
|
"""The project ID is a valid UUID."""
|
|
manifest = create_manifest("my-project")
|
|
UUID(manifest["id"]) # Raises ValueError if invalid
|
|
|
|
def test_custom_uuid(self) -> None:
|
|
"""A custom UUID can be provided."""
|
|
from uuid import uuid4
|
|
|
|
custom_id = uuid4()
|
|
manifest = create_manifest("my-project", project_id=custom_id)
|
|
assert manifest["id"] == str(custom_id)
|
|
|
|
def test_name_matches(self) -> None:
|
|
"""The project name matches the input."""
|
|
manifest = create_manifest("my-analysis-project")
|
|
assert manifest["name"] == "my-analysis-project"
|
|
|
|
def test_timestamps_are_iso8601(self) -> None:
|
|
"""Timestamps are ISO 8601 formatted."""
|
|
manifest = create_manifest("my-project")
|
|
datetime.fromisoformat(manifest["created_at"])
|
|
datetime.fromisoformat(manifest["updated_at"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Save and Load manifest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSaveAndLoadManifest:
|
|
"""Tests for save_manifest and load_manifest."""
|
|
|
|
def test_save_and_load_roundtrip(self, project_dir: str) -> None:
|
|
"""Saving and loading a manifest preserves all fields."""
|
|
manifest = create_manifest("roundtrip-test")
|
|
save_manifest(project_dir, manifest)
|
|
loaded = load_manifest(project_dir)
|
|
assert loaded["id"] == manifest["id"]
|
|
assert loaded["name"] == manifest["name"]
|
|
assert loaded["state"] == manifest["state"]
|
|
assert loaded["binary_count"] == manifest["binary_count"]
|
|
|
|
def test_file_is_valid_json(self, project_dir: str) -> None:
|
|
"""The saved project.json is valid JSON and parseable by json.load."""
|
|
manifest = create_manifest("my-project")
|
|
save_manifest(project_dir, manifest)
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path) as f:
|
|
parsed = json.load(f)
|
|
assert parsed == manifest
|
|
|
|
def test_load_nonexistent_raises(self, project_dir: str) -> None:
|
|
"""Loading a manifest that doesn't exist raises FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError, match="not found"):
|
|
load_manifest(project_dir)
|
|
|
|
def test_load_corrupted_json_raises(self, project_dir: str) -> None:
|
|
"""Loading corrupted (invalid) JSON raises InvalidConfigError, exit code 4."""
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path, "w") as f:
|
|
f.write("{invalid json")
|
|
|
|
with pytest.raises(InvalidConfigError, match="Corrupted project manifest") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_load_malformed_json_random_text(self, project_dir: str) -> None:
|
|
"""Loading non-JSON text raises InvalidConfigError with exit code 4."""
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path, "w") as f:
|
|
f.write("just some random text, not JSON at all")
|
|
|
|
with pytest.raises(InvalidConfigError, match="Corrupted project manifest") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_load_truncated_json(self, project_dir: str) -> None:
|
|
"""Loading truncated JSON raises InvalidConfigError (exit code 4)."""
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
valid = create_manifest("my-project")
|
|
json_str = json.dumps(valid)
|
|
# Truncate mid-key
|
|
truncated = json_str[: len(json_str) // 2]
|
|
with open(manifest_path, "w") as f:
|
|
f.write(truncated)
|
|
|
|
with pytest.raises(InvalidConfigError, match="Corrupted project manifest") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_load_empty_file(self, project_dir: str) -> None:
|
|
"""Loading an empty file raises InvalidConfigError (exit code 4)."""
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path, "w") as f:
|
|
f.write("")
|
|
|
|
with pytest.raises(InvalidConfigError, match="Corrupted project manifest") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_load_list_not_dict(self, project_dir: str) -> None:
|
|
"""Loading a JSON array instead of object raises InvalidConfigError."""
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path, "w") as f:
|
|
json.dump([1, 2, 3], f)
|
|
|
|
with pytest.raises(InvalidConfigError, match="Corrupted project manifest") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_load_missing_required_fields(self, project_dir: str) -> None:
|
|
"""Loading manifest with missing required fields raises InvalidConfigError."""
|
|
manifest = {"id": "123", "name": "test"} # Missing state, created_at, etc.
|
|
manifest_path = os.path.join(project_dir, "project.json")
|
|
with open(manifest_path, "w") as f:
|
|
json.dump(manifest, f)
|
|
|
|
with pytest.raises(InvalidConfigError, match="missing required fields") as excinfo:
|
|
load_manifest(project_dir)
|
|
assert excinfo.value.exit_code == 4
|
|
|
|
def test_atomic_save(self, project_dir: str) -> None:
|
|
"""Save is atomic — no .tmp files remain, file is valid JSON."""
|
|
manifest = create_manifest("atomic-test")
|
|
save_manifest(project_dir, manifest)
|
|
# No temp files left behind
|
|
project_path = Path(project_dir)
|
|
tmp_files = list(project_path.glob("*.tmp"))
|
|
assert len(tmp_files) == 0
|
|
# File is valid JSON
|
|
manifest_path = project_path / "project.json"
|
|
loaded = json.loads(manifest_path.read_text("utf-8"))
|
|
assert loaded["name"] == "atomic-test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update manifest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateManifestField:
|
|
"""Tests for update_manifest_field."""
|
|
|
|
def test_update_state(self, project_dir: str) -> None:
|
|
"""Updating fields atomically updates state in project.json."""
|
|
manifest = create_manifest("update-test")
|
|
save_manifest(project_dir, manifest)
|
|
|
|
updated = update_manifest_field(
|
|
project_dir,
|
|
{"state": ProjectState.IMPORTED.value, "binary_count": 1},
|
|
)
|
|
assert updated["state"] == ProjectState.IMPORTED.value
|
|
assert updated["binary_count"] == 1
|
|
assert updated["name"] == "update-test" # Unchanged
|
|
|
|
def test_updated_at_changes(self, project_dir: str) -> None:
|
|
"""updated_at is refreshed on every update."""
|
|
manifest = create_manifest("update-test")
|
|
save_manifest(project_dir, manifest)
|
|
original_updated_at = manifest["updated_at"]
|
|
|
|
updated = update_manifest_field(project_dir, {"binary_count": 5})
|
|
assert updated["updated_at"] != original_updated_at
|
|
|
|
def test_update_nonexistent_raises(self, project_dir: str) -> None:
|
|
"""Updating a nonexistent project raises FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError):
|
|
update_manifest_field(project_dir, {"state": "CREATED"})
|
|
|
|
def test_update_to_stale(self, project_dir: str) -> None:
|
|
"""Staleness can be updated atomically."""
|
|
manifest = create_manifest("stale-test")
|
|
save_manifest(project_dir, manifest)
|
|
|
|
updated = update_manifest_field(project_dir, {"is_stale": True})
|
|
assert updated["is_stale"] is True
|
|
|
|
def test_update_lock(self, project_dir: str) -> None:
|
|
"""Lock status can be updated."""
|
|
manifest = create_manifest("lock-test")
|
|
save_manifest(project_dir, manifest)
|
|
|
|
lock_info = {"holder": "process-12345", "acquired_at": "2026-07-29T12:00:00Z"}
|
|
updated = update_manifest_field(project_dir, {"lock": lock_info})
|
|
assert updated["lock"] == lock_info
|
|
|
|
def test_update_lock_release(self, project_dir: str) -> None:
|
|
"""Lock can be released (set to None)."""
|
|
manifest = create_manifest("lock-test")
|
|
manifest["lock"] = {"holder": "process-12345"}
|
|
save_manifest(project_dir, manifest)
|
|
|
|
updated = update_manifest_field(project_dir, {"lock": None})
|
|
assert updated["lock"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Save validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSaveValidation:
|
|
"""Tests for save_manifest validation."""
|
|
|
|
def test_save_rejects_missing_required_fields(self, project_dir: str) -> None:
|
|
"""Saving a manifest with missing fields raises InvalidConfigError."""
|
|
bad_manifest = {"name": "test"} # Missing required fields
|
|
with pytest.raises(InvalidConfigError, match="missing required fields"):
|
|
save_manifest(project_dir, bad_manifest)
|
|
|
|
def test_save_rejects_partial_manifest(self, project_dir: str) -> None:
|
|
"""Saving a manifest with only some required fields raises InvalidConfigError."""
|
|
bad_manifest = {
|
|
"id": "123",
|
|
"name": "test",
|
|
"state": "CREATED",
|
|
# Missing created_at, workspace_version, binary_count, is_stale
|
|
}
|
|
with pytest.raises(InvalidConfigError, match="missing required fields"):
|
|
save_manifest(project_dir, bad_manifest)
|