LangGraph-based release automation agent with: - PR discovery (webhook + polling) - AI code review via Claude Code CLI (subscription-based) - Auto-create Jira tickets for PRs without ticket ID - Jira ticket lifecycle management (code review -> staging -> done) - CI/CD pipeline trigger, polling, and approval gates - Slack interactive messages with approval buttons - Per-repo semantic versioning - PostgreSQL persistence (threads, staging, releases) - FastAPI API (webhooks, approvals, status, manual triggers) - Docker Compose deployment 1069 tests, 95%+ coverage.
178 lines
7.0 KiB
Python
178 lines
7.0 KiB
Python
"""Tests for async StagingStore protocol and async JsonFileStagingStore.
|
|
|
|
Phase 5 - Step 1: All StagingStore methods become async def.
|
|
Written FIRST (TDD RED phase).
|
|
"""
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from release_agent.graph.dependencies import JsonFileStagingStore, StagingStore, ToolClients
|
|
from release_agent.models.release import StagingRelease
|
|
from release_agent.models.ticket import TicketEntry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_ticket(ticket_id: str = "ALLPOST-1") -> TicketEntry:
|
|
return TicketEntry(
|
|
id=ticket_id,
|
|
summary="Fix something",
|
|
pr_id="42",
|
|
pr_url="https://dev.azure.com/org/proj/_git/repo/pullrequest/42",
|
|
pr_title="Fix: something",
|
|
branch=f"feature/{ticket_id}-fix",
|
|
merged_at=date(2025, 1, 15),
|
|
)
|
|
|
|
|
|
def _make_staging(repo: str = "my-repo", version: str = "v1.0.0") -> StagingRelease:
|
|
return StagingRelease(
|
|
version=version,
|
|
repo=repo,
|
|
started_at=date(2025, 1, 1),
|
|
tickets=[],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Protocol: all methods must be async
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStagingStoreProtocolIsAsync:
|
|
"""Verify that StagingStore protocol methods are async-compatible."""
|
|
|
|
def test_protocol_has_load_method(self) -> None:
|
|
assert hasattr(StagingStore, "load")
|
|
|
|
def test_protocol_has_save_method(self) -> None:
|
|
assert hasattr(StagingStore, "save")
|
|
|
|
def test_protocol_has_archive_method(self) -> None:
|
|
assert hasattr(StagingStore, "archive")
|
|
|
|
def test_protocol_has_list_versions_method(self) -> None:
|
|
assert hasattr(StagingStore, "list_versions")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JsonFileStagingStore async interface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestJsonFileStagingStoreAsync:
|
|
"""Verify that JsonFileStagingStore methods are awaitable (async def)."""
|
|
|
|
async def test_load_is_awaitable(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
result = await store.load("nonexistent-repo")
|
|
assert result is None
|
|
|
|
async def test_load_returns_staging_release_after_save(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging()
|
|
await store.save(staging)
|
|
loaded = await store.load("my-repo")
|
|
assert loaded is not None
|
|
assert loaded.version == "v1.0.0"
|
|
assert loaded.repo == "my-repo"
|
|
|
|
async def test_load_returns_staging_with_tickets(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging().add_ticket(_make_ticket("BILL-10"))
|
|
await store.save(staging)
|
|
loaded = await store.load("my-repo")
|
|
assert loaded is not None
|
|
assert len(loaded.tickets) == 1
|
|
assert loaded.tickets[0].id == "BILL-10"
|
|
|
|
async def test_load_returns_fresh_objects(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging()
|
|
await store.save(staging)
|
|
loaded1 = await store.load("my-repo")
|
|
loaded2 = await store.load("my-repo")
|
|
assert loaded1 is not loaded2
|
|
|
|
async def test_save_is_awaitable(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging(repo="api-service")
|
|
await store.save(staging)
|
|
expected_path = tmp_path / "api-service.json"
|
|
assert expected_path.exists()
|
|
|
|
async def test_save_overwrites_existing_file(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
await store.save(_make_staging(version="v1.0.0"))
|
|
await store.save(_make_staging(version="v1.0.1"))
|
|
loaded = await store.load("my-repo")
|
|
assert loaded is not None
|
|
assert loaded.version == "v1.0.1"
|
|
|
|
async def test_save_writes_valid_json(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging()
|
|
await store.save(staging)
|
|
raw = (tmp_path / "my-repo.json").read_text()
|
|
data = json.loads(raw)
|
|
assert data["version"] == "v1.0.0"
|
|
assert data["repo"] == "my-repo"
|
|
|
|
async def test_archive_is_awaitable(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging()
|
|
await store.save(staging)
|
|
await store.archive(staging, date(2025, 6, 1))
|
|
assert await store.load("my-repo") is None
|
|
|
|
async def test_archive_creates_archive_file(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging(repo="my-repo", version="v1.0.0")
|
|
await store.save(staging)
|
|
await store.archive(staging, date(2025, 6, 1))
|
|
archive_path = tmp_path / "my-repo_v1.0.0_2025-06-01.json"
|
|
assert archive_path.exists()
|
|
|
|
async def test_archive_file_contains_released_at(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging()
|
|
await store.save(staging)
|
|
await store.archive(staging, date(2025, 6, 1))
|
|
archive_path = tmp_path / "my-repo_v1.0.0_2025-06-01.json"
|
|
data = json.loads(archive_path.read_text())
|
|
assert data["released_at"] == "2025-06-01"
|
|
|
|
async def test_list_versions_is_awaitable(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
versions = await store.list_versions("my-repo")
|
|
assert versions == []
|
|
|
|
async def test_list_versions_returns_staging_version(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
await store.save(_make_staging(version="v2.1.0"))
|
|
versions = await store.list_versions("my-repo")
|
|
assert "v2.1.0" in versions
|
|
|
|
async def test_list_versions_includes_archived(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
staging = _make_staging(version="v1.5.0")
|
|
await store.save(staging)
|
|
await store.archive(staging, date(2025, 3, 1))
|
|
await store.save(_make_staging(version="v1.6.0"))
|
|
versions = await store.list_versions("my-repo")
|
|
assert "v1.5.0" in versions
|
|
assert "v1.6.0" in versions
|
|
|
|
async def test_list_versions_only_for_given_repo(self, tmp_path: Path) -> None:
|
|
store = JsonFileStagingStore(directory=tmp_path)
|
|
await store.save(_make_staging(repo="repo-a", version="v1.0.0"))
|
|
await store.save(_make_staging(repo="repo-b", version="v2.0.0"))
|
|
versions_a = await store.list_versions("repo-a")
|
|
assert "v1.0.0" in versions_a
|
|
assert "v2.0.0" not in versions_a
|