"""Tests for graph/dependencies.py. Written FIRST (TDD RED phase). Covers: - ToolClients frozen dataclass - StagingStore Protocol (structural check) - JsonFileStagingStore file I/O operations """ 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 ArchivedRelease, 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=[], ) # --------------------------------------------------------------------------- # ToolClients tests # --------------------------------------------------------------------------- class TestToolClients: """Tests for the ToolClients frozen dataclass.""" def test_can_be_constructed_with_all_fields(self) -> None: azdo = AsyncMock() jira = AsyncMock() slack = AsyncMock() reviewer = AsyncMock() clients = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer) assert clients.azdo is azdo assert clients.jira is jira assert clients.slack is slack assert clients.reviewer is reviewer def test_is_frozen_cannot_reassign_field(self) -> None: clients = ToolClients( azdo=AsyncMock(), jira=AsyncMock(), slack=AsyncMock(), reviewer=AsyncMock() ) with pytest.raises((AttributeError, TypeError)): clients.azdo = AsyncMock() # type: ignore[misc] def test_fields_are_accessible_by_name(self) -> None: azdo = object() clients = ToolClients( azdo=azdo, jira=object(), slack=object(), reviewer=object() ) assert clients.azdo is azdo def test_equality_for_same_instances(self) -> None: azdo = AsyncMock() jira = AsyncMock() slack = AsyncMock() reviewer = AsyncMock() c1 = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer) c2 = ToolClients(azdo=azdo, jira=jira, slack=slack, reviewer=reviewer) assert c1 == c2 # --------------------------------------------------------------------------- # StagingStore Protocol structural tests # --------------------------------------------------------------------------- class TestStagingStoreProtocol: """Verify that the Protocol is structurally correct.""" def test_json_file_store_satisfies_protocol(self, tmp_path: Path) -> None: store = JsonFileStagingStore(directory=tmp_path) # runtime_checkable would need @runtime_checkable; check duck-typing instead assert hasattr(store, "load") assert hasattr(store, "save") assert hasattr(store, "archive") assert hasattr(store, "list_versions") def test_protocol_is_importable(self) -> None: # Just import-level check assert StagingStore is not None # --------------------------------------------------------------------------- # JsonFileStagingStore tests # --------------------------------------------------------------------------- class TestJsonFileStagingStore: """Tests for JsonFileStagingStore using tmp_path for file I/O.""" # ------------------------------------------------------------------ # load # ------------------------------------------------------------------ async def test_load_returns_none_when_file_missing(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_is_read_only_does_not_mutate_stored(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 # fresh objects each time # ------------------------------------------------------------------ # save # ------------------------------------------------------------------ async def test_save_creates_file_in_directory(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) staging_v1 = _make_staging(version="v1.0.0") staging_v2 = _make_staging(version="v1.0.1") await store.save(staging_v1) await store.save(staging_v2) 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_save_does_not_mutate_staging_release(self, tmp_path: Path) -> None: store = JsonFileStagingStore(directory=tmp_path) staging = _make_staging() original_tickets = list(staging.tickets) await store.save(staging) assert list(staging.tickets) == original_tickets # ------------------------------------------------------------------ # archive # ------------------------------------------------------------------ async def test_archive_removes_staging_file(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) release_date = date(2025, 6, 1) await store.archive(staging, release_date) 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_archive_without_prior_save_creates_archive(self, tmp_path: Path) -> None: store = JsonFileStagingStore(directory=tmp_path) staging = _make_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() # ------------------------------------------------------------------ # list_versions # ------------------------------------------------------------------ async def test_list_versions_empty_directory(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_version_from_staging_file(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_versions(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)) # Now save a new staging for the same repo 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_returns_versions_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 # repo-b version should not appear in repo-a's list assert "v2.0.0" not in versions_a async def test_list_versions_no_duplicates(self, tmp_path: Path) -> None: store = JsonFileStagingStore(directory=tmp_path) await store.save(_make_staging(version="v1.0.0")) versions = await store.list_versions("my-repo") assert len(versions) == len(set(versions)) async def test_list_versions_multiple_archives(self, tmp_path: Path) -> None: store = JsonFileStagingStore(directory=tmp_path) for i in range(3): staging = _make_staging(version=f"v1.0.{i}") await store.archive(staging, date(2025, 1, i + 1)) versions = await store.list_versions("my-repo") assert len(versions) == 3 assert "v1.0.0" in versions assert "v1.0.1" in versions assert "v1.0.2" in versions # ------------------------------------------------------------------ # directory creation # ------------------------------------------------------------------ def test_store_creates_directory_if_not_exists(self, tmp_path: Path) -> None: new_dir = tmp_path / "staging_data" assert not new_dir.exists() JsonFileStagingStore(directory=new_dir) assert new_dir.exists()