"""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)