Files
billo-release-agent/tests/scripts/test_migrate.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
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.
2026-03-24 17:38:23 +01:00

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)