184 lines
5.1 KiB
Python
184 lines
5.1 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import importlib.util
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
def load_script_module():
|
||
|
|
script_path = (
|
||
|
|
Path(__file__).resolve().parents[1] / "scripts" / "backfill_legacy_expense_claim_cases.py"
|
||
|
|
)
|
||
|
|
spec = importlib.util.spec_from_file_location(
|
||
|
|
"backfill_legacy_expense_claim_cases_cli",
|
||
|
|
script_path,
|
||
|
|
)
|
||
|
|
assert spec is not None and spec.loader is not None
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
return module
|
||
|
|
|
||
|
|
|
||
|
|
MODULE = load_script_module()
|
||
|
|
|
||
|
|
|
||
|
|
def test_created_before_requires_timezone_and_normalizes_to_utc() -> None:
|
||
|
|
assert MODULE.parse_created_before("2026-07-14T08:00:00+08:00") == datetime(
|
||
|
|
2026,
|
||
|
|
7,
|
||
|
|
14,
|
||
|
|
tzinfo=UTC,
|
||
|
|
)
|
||
|
|
with pytest.raises(argparse.ArgumentTypeError, match="时区"):
|
||
|
|
MODULE.parse_created_before("2026-07-14T00:00:00")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("value", ["0", "-1", "not-a-number"])
|
||
|
|
def test_positive_int_rejects_invalid_values(value: str) -> None:
|
||
|
|
with pytest.raises(argparse.ArgumentTypeError):
|
||
|
|
MODULE.positive_int(value)
|
||
|
|
|
||
|
|
|
||
|
|
def test_parser_defaults_to_dry_run_and_requires_explicit_scope() -> None:
|
||
|
|
args = MODULE.build_parser().parse_args(
|
||
|
|
[
|
||
|
|
"--tenant-id",
|
||
|
|
"default",
|
||
|
|
"--created-before",
|
||
|
|
"2026-07-14T00:00:00Z",
|
||
|
|
"--expected-host",
|
||
|
|
"migration-probe",
|
||
|
|
"--expected-database",
|
||
|
|
"migration_probe",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
assert args.apply is False
|
||
|
|
assert args.dry_run is False
|
||
|
|
assert args.batch_size == 100
|
||
|
|
assert args.tenant_id == "default"
|
||
|
|
|
||
|
|
|
||
|
|
def test_parser_rejects_apply_and_dry_run_together() -> None:
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
MODULE.build_parser().parse_args(
|
||
|
|
[
|
||
|
|
"--apply",
|
||
|
|
"--dry-run",
|
||
|
|
"--tenant-id",
|
||
|
|
"default",
|
||
|
|
"--created-before",
|
||
|
|
"2026-07-14T00:00:00Z",
|
||
|
|
"--expected-host",
|
||
|
|
"migration-probe",
|
||
|
|
"--expected-database",
|
||
|
|
"migration_probe",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_parser_rejects_blank_tenant() -> None:
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
MODULE.build_parser().parse_args(
|
||
|
|
[
|
||
|
|
"--tenant-id",
|
||
|
|
" ",
|
||
|
|
"--created-before",
|
||
|
|
"2026-07-14T00:00:00Z",
|
||
|
|
"--expected-host",
|
||
|
|
"migration-probe",
|
||
|
|
"--expected-database",
|
||
|
|
"migration_probe",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_apply_requires_exact_target_confirmation_before_connecting(monkeypatch) -> None:
|
||
|
|
monkeypatch.setenv(
|
||
|
|
"DATABASE_URL",
|
||
|
|
"postgresql://finance:secret@migration-probe/migration_probe",
|
||
|
|
)
|
||
|
|
args = MODULE.build_parser().parse_args(
|
||
|
|
[
|
||
|
|
"--apply",
|
||
|
|
"--tenant-id",
|
||
|
|
"default",
|
||
|
|
"--created-before",
|
||
|
|
"2026-07-14T00:00:00Z",
|
||
|
|
"--expected-host",
|
||
|
|
"migration-probe",
|
||
|
|
"--expected-database",
|
||
|
|
"migration_probe",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(MODULE.BackfillCommandError) as exc_info:
|
||
|
|
MODULE.run(args)
|
||
|
|
|
||
|
|
assert exc_info.value.code == "confirm_target_required"
|
||
|
|
assert exc_info.value.exit_code == MODULE.EXIT_SAFETY
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_database_url_is_rejected_without_configuration_fallback(monkeypatch) -> None:
|
||
|
|
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||
|
|
args = MODULE.build_parser().parse_args(
|
||
|
|
[
|
||
|
|
"--tenant-id",
|
||
|
|
"default",
|
||
|
|
"--created-before",
|
||
|
|
"2026-07-14T00:00:00Z",
|
||
|
|
"--expected-host",
|
||
|
|
"migration-probe",
|
||
|
|
"--expected-database",
|
||
|
|
"migration_probe",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(MODULE.MaintenanceDatabaseTargetError) as exc_info:
|
||
|
|
MODULE.run(args)
|
||
|
|
|
||
|
|
assert exc_info.value.code == "database_url_required"
|
||
|
|
|
||
|
|
|
||
|
|
def test_partial_failure_payload_discloses_committed_progress() -> None:
|
||
|
|
summary = {
|
||
|
|
"run_id": "historical-import-run-1",
|
||
|
|
"batches": 2,
|
||
|
|
"inspected": 4,
|
||
|
|
"created": 3,
|
||
|
|
"already_linked": 1,
|
||
|
|
"last_cursor": {
|
||
|
|
"created_at": "2026-07-10T00:00:00Z",
|
||
|
|
"claim_id": "claim-4",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
details = MODULE._execution_progress(
|
||
|
|
summary,
|
||
|
|
current_batch_conflict_ids=["claim-5"],
|
||
|
|
)
|
||
|
|
error = MODULE.BackfillCommandError(
|
||
|
|
"current batch rolled back",
|
||
|
|
exit_code=MODULE.EXIT_CONFLICT,
|
||
|
|
code="legacy_claim_conflict",
|
||
|
|
details=details,
|
||
|
|
)
|
||
|
|
|
||
|
|
payload = MODULE._error_payload(error, code=error.code, details=error.details)
|
||
|
|
|
||
|
|
assert payload["details"] == {
|
||
|
|
"run_id": "historical-import-run-1",
|
||
|
|
"partial_commit": True,
|
||
|
|
"committed_batches": 2,
|
||
|
|
"committed_claims": 4,
|
||
|
|
"created": 3,
|
||
|
|
"already_linked": 1,
|
||
|
|
"last_committed_cursor": {
|
||
|
|
"created_at": "2026-07-10T00:00:00Z",
|
||
|
|
"claim_id": "claim-4",
|
||
|
|
},
|
||
|
|
"current_batch_conflict_ids": ["claim-5"],
|
||
|
|
}
|