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.
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""Tests for scripts/migrate_json_to_db.py.
|
|
|
|
Phase 5 - Step 5: Migration script tests using pure functions and
|
|
dry-run mode. No real database required.
|
|
Written FIRST (TDD RED phase).
|
|
"""
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from scripts.migrate_json_to_db import (
|
|
collect_json_files,
|
|
parse_staging_json,
|
|
parse_archived_json,
|
|
build_staging_insert_sql,
|
|
build_archived_insert_sql,
|
|
is_archived_filename,
|
|
is_staging_filename,
|
|
MigrationRecord,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture data (mirrors real JSON structure from release-workflow/releases/)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
STAGING_JSON = {
|
|
"version": "v1.0.0",
|
|
"repo": "Billo.Platform.Document",
|
|
"started_at": "2026-03-17",
|
|
"tickets": [
|
|
{
|
|
"id": "ALLPOST-4219",
|
|
"summary": "Test release bot",
|
|
"pr_id": "10460",
|
|
"pr_url": "https://dev.azure.com/billodev/Billo%20App%20Platform/_git/Billo.Platform.Document/pullrequest/10460",
|
|
"pr_title": "chore: trigger release bot test",
|
|
"branch": "feature_ALLPOST-4219_test_release_bot",
|
|
"merged_at": "2026-03-17",
|
|
}
|
|
],
|
|
}
|
|
|
|
PAYMENT_STAGING_JSON = {
|
|
"version": "v1.0.1",
|
|
"repo": "Billo.Platform.Payment",
|
|
"started_at": "2026-03-23",
|
|
"tickets": [
|
|
{
|
|
"id": "ALLPOST-4228",
|
|
"summary": "Invoice upload fails on Hangfire retry - BlobAlreadyExists 409",
|
|
"pr_id": "10481",
|
|
"pr_url": "https://dev.azure.com/billodev/Billo%20App%20Platform/_git/Billo.Platform.Payment/pullrequest/10481",
|
|
"pr_title": "Invoice upload fails on Hangfire retry - BlobAlreadyExists 409",
|
|
"branch": "bug/ALLPOST-4228_fix-invoice-upload-blob-already-exists",
|
|
"merged_at": "2026-03-23",
|
|
}
|
|
],
|
|
}
|
|
|
|
# Archived JSON has an additional released_at field
|
|
ARCHIVED_JSON = {
|
|
"version": "v1.0.0",
|
|
"repo": "Billo.Platform.Payment",
|
|
"started_at": "2026-01-01",
|
|
"tickets": [],
|
|
"released_at": "2026-01-15",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_staging_filename / is_archived_filename
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFileNameClassification:
|
|
def test_staging_filename_identified(self) -> None:
|
|
assert is_staging_filename("Billo.Platform.Document.json") is True
|
|
|
|
def test_archived_filename_identified(self) -> None:
|
|
assert is_archived_filename("Billo.Platform.Payment_v1.0.1_2026-03-23.json") is True
|
|
|
|
def test_staging_filename_not_archived(self) -> None:
|
|
assert is_archived_filename("Billo.Platform.Document.json") is False
|
|
|
|
def test_archived_filename_not_staging(self) -> None:
|
|
assert is_staging_filename("Billo.Platform.Payment_v1.0.1_2026-03-23.json") is False
|
|
|
|
def test_non_json_file_is_not_staging(self) -> None:
|
|
assert is_staging_filename("README.md") is False
|
|
|
|
def test_non_json_file_is_not_archived(self) -> None:
|
|
assert is_archived_filename("README.md") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_staging_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseStagingJson:
|
|
def test_parse_returns_migration_record(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert isinstance(record, MigrationRecord)
|
|
|
|
def test_parse_extracts_repo(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert record.repo == "Billo.Platform.Document"
|
|
|
|
def test_parse_extracts_version(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert record.version == "v1.0.0"
|
|
|
|
def test_parse_extracts_started_at(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert record.started_at == date(2026, 3, 17)
|
|
|
|
def test_parse_extracts_tickets(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert len(record.tickets) == 1
|
|
assert record.tickets[0]["id"] == "ALLPOST-4219"
|
|
|
|
def test_parse_staging_has_no_released_at(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
assert record.released_at is None
|
|
|
|
def test_parse_staging_with_multiple_tickets(self) -> None:
|
|
data = {
|
|
**STAGING_JSON,
|
|
"tickets": [
|
|
{**STAGING_JSON["tickets"][0], "id": "ALLPOST-1"},
|
|
{**STAGING_JSON["tickets"][0], "id": "ALLPOST-2"},
|
|
],
|
|
}
|
|
record = parse_staging_json(data)
|
|
assert len(record.tickets) == 2
|
|
|
|
def test_parse_staging_with_empty_tickets(self) -> None:
|
|
data = {**STAGING_JSON, "tickets": []}
|
|
record = parse_staging_json(data)
|
|
assert record.tickets == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_archived_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseArchivedJson:
|
|
def test_parse_returns_migration_record(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
assert isinstance(record, MigrationRecord)
|
|
|
|
def test_parse_extracts_released_at(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
assert record.released_at == date(2026, 1, 15)
|
|
|
|
def test_parse_extracts_repo(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
assert record.repo == "Billo.Platform.Payment"
|
|
|
|
def test_parse_extracts_version(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
assert record.version == "v1.0.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_staging_insert_sql
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildStagingInsertSql:
|
|
def test_returns_tuple_of_sql_and_params(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
sql, params = build_staging_insert_sql(record)
|
|
assert isinstance(sql, str)
|
|
assert isinstance(params, tuple)
|
|
|
|
def test_sql_inserts_into_staging_releases(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
sql, _ = build_staging_insert_sql(record)
|
|
assert "staging_releases" in sql
|
|
|
|
def test_sql_is_insert_statement(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
sql, _ = build_staging_insert_sql(record)
|
|
assert "INSERT" in sql.upper()
|
|
|
|
def test_params_include_repo(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
_, params = build_staging_insert_sql(record)
|
|
assert "Billo.Platform.Document" in params
|
|
|
|
def test_params_include_version(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
_, params = build_staging_insert_sql(record)
|
|
assert "v1.0.0" in params
|
|
|
|
def test_params_include_started_at(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
_, params = build_staging_insert_sql(record)
|
|
assert "2026-03-17" in params
|
|
|
|
def test_params_include_tickets_json(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
_, params = build_staging_insert_sql(record)
|
|
# tickets should be serialized as JSON string
|
|
tickets_json = next(p for p in params if isinstance(p, str) and "ALLPOST-4219" in p)
|
|
parsed = json.loads(tickets_json)
|
|
assert parsed[0]["id"] == "ALLPOST-4219"
|
|
|
|
def test_sql_uses_on_conflict_do_nothing_or_update(self) -> None:
|
|
record = parse_staging_json(STAGING_JSON)
|
|
sql, _ = build_staging_insert_sql(record)
|
|
assert "ON CONFLICT" in sql.upper() or "INSERT" in sql.upper()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_archived_insert_sql
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildArchivedInsertSql:
|
|
def test_returns_tuple_of_sql_and_params(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
sql, params = build_archived_insert_sql(record)
|
|
assert isinstance(sql, str)
|
|
assert isinstance(params, tuple)
|
|
|
|
def test_sql_inserts_into_archived_releases(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
sql, _ = build_archived_insert_sql(record)
|
|
assert "archived_releases" in sql
|
|
|
|
def test_sql_is_insert_statement(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
sql, _ = build_archived_insert_sql(record)
|
|
assert "INSERT" in sql.upper()
|
|
|
|
def test_params_include_released_at(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
_, params = build_archived_insert_sql(record)
|
|
assert "2026-01-15" in params
|
|
|
|
def test_params_include_repo(self) -> None:
|
|
record = parse_archived_json(ARCHIVED_JSON)
|
|
_, params = build_archived_insert_sql(record)
|
|
assert "Billo.Platform.Payment" in params
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# collect_json_files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCollectJsonFiles:
|
|
def test_returns_empty_list_for_empty_directory(self, tmp_path: Path) -> None:
|
|
result = collect_json_files(tmp_path)
|
|
assert result == []
|
|
|
|
def test_finds_staging_json_files(self, tmp_path: Path) -> None:
|
|
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
|
result = collect_json_files(tmp_path)
|
|
assert len(result) == 1
|
|
|
|
def test_finds_archived_json_files(self, tmp_path: Path) -> None:
|
|
(tmp_path / "my-repo_v1.0.0_2025-06-01.json").write_text(
|
|
json.dumps(ARCHIVED_JSON)
|
|
)
|
|
result = collect_json_files(tmp_path)
|
|
assert len(result) == 1
|
|
|
|
def test_ignores_non_json_files(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("readme")
|
|
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
|
result = collect_json_files(tmp_path)
|
|
assert len(result) == 1
|
|
|
|
def test_collects_from_nested_directories(self, tmp_path: Path) -> None:
|
|
repo_dir = tmp_path / "Billo.Platform.Document"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "v1.0.0.json").write_text(json.dumps(STAGING_JSON))
|
|
result = collect_json_files(tmp_path)
|
|
assert len(result) == 1
|
|
|
|
def test_returns_path_objects(self, tmp_path: Path) -> None:
|
|
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
|
result = collect_json_files(tmp_path)
|
|
assert all(isinstance(p, Path) for p in result)
|
|
|
|
def test_collects_multiple_files(self, tmp_path: Path) -> None:
|
|
for i in range(3):
|
|
(tmp_path / f"repo-{i}.json").write_text(json.dumps(STAGING_JSON))
|
|
result = collect_json_files(tmp_path)
|
|
assert len(result) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dry-run mode (integration of pure functions)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDryRunMode:
|
|
def test_dry_run_collects_records_without_db_access(self, tmp_path: Path) -> None:
|
|
"""Dry run processes files and returns SQL/params without executing."""
|
|
repo_dir = tmp_path / "Billo.Platform.Document"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "v1.0.0.json").write_text(json.dumps(STAGING_JSON))
|
|
|
|
files = collect_json_files(tmp_path)
|
|
assert len(files) == 1
|
|
|
|
# Parse and build SQL — no DB connection needed
|
|
record = parse_staging_json(json.loads(files[0].read_text()))
|
|
sql, params = build_staging_insert_sql(record)
|
|
assert "INSERT" in sql.upper()
|
|
assert "Billo.Platform.Document" in params
|
|
|
|
def test_payment_staging_file_parses_correctly(self, tmp_path: Path) -> None:
|
|
(tmp_path / "Billo.Platform.Payment.json").write_text(
|
|
json.dumps(PAYMENT_STAGING_JSON)
|
|
)
|
|
files = collect_json_files(tmp_path)
|
|
record = parse_staging_json(json.loads(files[0].read_text()))
|
|
assert record.repo == "Billo.Platform.Payment"
|
|
assert record.version == "v1.0.1"
|
|
assert len(record.tickets) == 1
|
|
assert record.tickets[0]["id"] == "ALLPOST-4228"
|
|
|
|
def test_archived_file_parses_correctly(self, tmp_path: Path) -> None:
|
|
(tmp_path / "my-repo_v1.0.0_2026-01-15.json").write_text(
|
|
json.dumps(ARCHIVED_JSON)
|
|
)
|
|
files = collect_json_files(tmp_path)
|
|
record = parse_archived_json(json.loads(files[0].read_text()))
|
|
assert record.released_at == date(2026, 1, 15)
|