Newer
Older
TIASDataHub / tests / test_backup.py
"""目録と対応表のバックアップ(`tiasdata backup`.2026-10-08 のユーザー判断).

- 目録は `<catalog.root>/backups/catalog-<UTC YYYYmmddTHHMMSSZ>.sqlite`,対応表は
  **対応表のフォルダの** `backups/identity-<同じ日時>.sqlite` に,SQLite のバックアップ API で
  複製する
- 複製に `integrity_check` が ok・スキーマの版が最新であることを確かめ,件数を表示する
  (パスは出さない)
- 書き込む前に 2 つとも同じ名前が無いことを確かめ,あれば何も書かずに停止する(上書きしない)
- 一時ファイル → 点検 → 正式な名前.失敗時は一時ファイルを消す
- init 前・スキーマが古いときは 1 行のエラー
- `run_backup(config, *, now=None)` で日時を固定できる
"""

import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path

import pytest
from conftest import ImportEnv, write_reviews

from tiasdata import backup, catalog
from tiasdata.backup import backup_timestamp, run_backup
from tiasdata.catalog import backup as catalog_backup
from tiasdata.cli import main
from tiasdata.config import load_config
from tiasdata.importers import run_import

NOW = datetime(2026, 10, 8, 9, 0, 0, tzinfo=UTC)
STAMP = "20261008T090000Z"
DUMMY_KEYS = [str(990001 + i) for i in range(7)]


def _catalog_backups(env: ImportEnv) -> Path:
    return env.catalog_root / "backups"


def _identity_backups(env: ImportEnv) -> Path:
    return env.identity_path.parent / "backups"


def _files(directory: Path) -> list[str]:
    return sorted(p.name for p in directory.iterdir()) if directory.is_dir() else []


def _dump(path: Path) -> list[str]:
    with closing(sqlite3.connect(path)) as conn:
        return list(conn.iterdump())


def _version(path: Path) -> int:
    with closing(sqlite3.connect(path)) as conn:
        return conn.execute("PRAGMA user_version").fetchone()[0]


def _init() -> None:
    assert main(["init"]) == 0


def _assert_nothing_written(env: ImportEnv) -> None:
    assert _files(_catalog_backups(env)) == []
    assert _files(_identity_backups(env)) == []


def _assert_no_paths(text: str, env: ImportEnv) -> None:
    root = env.config_path.parents[1]
    for form in (str(root), root.as_posix(), str(root.resolve())):
        assert form not in text
    assert "catalog.sqlite" not in text
    assert "identity.sqlite" not in text
    for key in DUMMY_KEYS:
        assert key not in text


class TestTimestamp:
    def test_utc_format(self) -> None:
        assert backup_timestamp(NOW) == STAMP

    def test_other_timezone_is_converted_to_utc(self) -> None:
        jst = timezone(timedelta(hours=9))
        assert backup_timestamp(datetime(2026, 10, 8, 18, 0, 0, tzinfo=jst)) == STAMP

    def test_naive_is_treated_as_utc(self) -> None:
        assert backup_timestamp(datetime(2026, 10, 8, 9, 0, 0)) == STAMP

    def test_default_is_now(self) -> None:
        before = datetime.now(UTC).replace(microsecond=0)
        stamp = backup_timestamp()
        after = datetime.now(UTC)
        moment = datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
        assert before <= moment <= after


class TestRunBackup:
    def test_copies_to_each_backups_folder(self, import_env: ImportEnv) -> None:
        write_reviews(import_env.reviews_dir, "dummy_tongue", [3])
        _init()
        run_import(load_config(import_env.config_path), import_env.source_name)
        catalog_before = _dump(import_env.catalog_root / "catalog.sqlite")
        identity_before = import_env.identity_path.read_bytes()

        result = run_backup(load_config(import_env.config_path), now=NOW)

        assert result.timestamp == STAMP
        assert result.catalog.filename == f"catalog-{STAMP}.sqlite"
        assert result.identity.filename == f"identity-{STAMP}.sqlite"
        # 目録は目録のフォルダの backups/,対応表は対応表のフォルダの backups/(別の場所)
        assert _files(_catalog_backups(import_env)) == [f"catalog-{STAMP}.sqlite"]
        assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"]
        assert _catalog_backups(import_env) != _identity_backups(import_env)

        catalog_copy = _catalog_backups(import_env) / result.catalog.filename
        identity_copy = _identity_backups(import_env) / result.identity.filename
        # 中身は元と同じ.スキーマの版は最新
        assert _dump(catalog_copy) == catalog_before
        assert _dump(identity_copy) == _dump(import_env.identity_path)
        assert _version(catalog_copy) == catalog.latest_version("catalog")
        assert _version(identity_copy) == catalog.latest_version("identity")
        assert catalog_backup.integrity_problems(catalog_copy) == 0
        assert catalog_backup.integrity_problems(identity_copy) == 0
        # 複製元は変わらない
        assert _dump(import_env.catalog_root / "catalog.sqlite") == catalog_before
        assert import_env.identity_path.read_bytes() == identity_before

        # 件数(撮影 18・ファイル 18・所見 18 + 要確認の印 1・症例 7・対応表 7)
        assert result.catalog.counts == {
            "subjects": 7,
            "captures": 18,
            "assets": 18,
            "annotations": 19,
            "dataset_versions": 0,
        }
        assert result.identity.counts == {"identity_map": 7}

    def test_copy_can_be_opened_as_latest_catalog(self, import_env: ImportEnv) -> None:
        _init()
        result = run_backup(load_config(import_env.config_path), now=NOW)
        copy = _catalog_backups(import_env) / result.catalog.filename
        with closing(catalog.connect_latest(copy)) as conn:
            assert catalog.count_captures(conn) == 0

    def test_empty_catalog_counts_are_zero(self, import_env: ImportEnv) -> None:
        _init()
        result = run_backup(load_config(import_env.config_path), now=NOW)
        assert set(result.catalog.counts.values()) == {0}
        assert result.identity.counts == {"identity_map": 0}

    def test_different_times_make_separate_copies(self, import_env: ImportEnv) -> None:
        _init()
        config = load_config(import_env.config_path)
        run_backup(config, now=NOW)
        run_backup(config, now=NOW + timedelta(seconds=1))
        assert _files(_catalog_backups(import_env)) == [
            f"catalog-{STAMP}.sqlite",
            "catalog-20261008T090001Z.sqlite",
        ]
        assert len(_files(_identity_backups(import_env))) == 2

    def test_no_temporary_files_remain(self, import_env: ImportEnv) -> None:
        _init()
        run_backup(load_config(import_env.config_path), now=NOW)
        for directory in (_catalog_backups(import_env), _identity_backups(import_env)):
            assert not [n for n in _files(directory) if n.startswith(backup.TEMP_PREFIX)]


class TestDoesNotOverwrite:
    @pytest.mark.parametrize("which", ["catalog", "identity"])
    def test_existing_name_stops_without_writing(self, import_env: ImportEnv, which: str) -> None:
        _init()
        if which == "catalog":
            existing = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite"
            other = _identity_backups(import_env)
        else:
            existing = _identity_backups(import_env) / f"identity-{STAMP}.sqlite"
            other = _catalog_backups(import_env)
        existing.parent.mkdir(parents=True, exist_ok=True)
        existing.write_bytes(b"existing-backup")

        with pytest.raises(ValueError) as excinfo:
            run_backup(load_config(import_env.config_path), now=NOW)
        message = str(excinfo.value)
        assert "\n" not in message
        _assert_no_paths(message, import_env)
        # 既存のファイルは変わらず,もう一方も書かない(一時ファイルも残らない)
        assert existing.read_bytes() == b"existing-backup"
        assert _files(existing.parent) == [existing.name]
        assert _files(other) == []

    def test_second_run_at_same_time_stops(self, import_env: ImportEnv) -> None:
        _init()
        config = load_config(import_env.config_path)
        run_backup(config, now=NOW)
        catalog_copy = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite"
        before = catalog_copy.read_bytes()
        with pytest.raises(ValueError):
            run_backup(config, now=NOW)
        assert catalog_copy.read_bytes() == before
        assert _files(_catalog_backups(import_env)) == [catalog_copy.name]
        assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"]


class TestFailures:
    def test_before_init_stops(self, import_env: ImportEnv) -> None:
        with pytest.raises(ValueError) as excinfo:
            run_backup(load_config(import_env.config_path), now=NOW)
        assert "\n" not in str(excinfo.value)
        _assert_no_paths(str(excinfo.value), import_env)
        _assert_nothing_written(import_env)

    def test_missing_identity_stops(self, import_env: ImportEnv) -> None:
        _init()
        import_env.identity_path.unlink()
        with pytest.raises(ValueError) as excinfo:
            run_backup(load_config(import_env.config_path), now=NOW)
        _assert_no_paths(str(excinfo.value), import_env)
        _assert_nothing_written(import_env)

    @pytest.mark.parametrize("which", ["catalog", "identity"])
    def test_old_schema_stops(self, import_env: ImportEnv, which: str) -> None:
        _init()
        path = import_env.catalog_root / "catalog.sqlite" if which == "catalog" else None
        path = path or import_env.identity_path
        with closing(sqlite3.connect(path)) as conn:
            conn.execute("PRAGMA user_version = 0")
        with pytest.raises(ValueError, match="init") as excinfo:
            run_backup(load_config(import_env.config_path), now=NOW)
        assert "\n" not in str(excinfo.value)
        _assert_no_paths(str(excinfo.value), import_env)
        _assert_nothing_written(import_env)

    def test_integrity_problem_leaves_nothing(
        self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        _init()
        monkeypatch.setattr(catalog_backup, "integrity_problems", lambda path: 2)
        with pytest.raises(ValueError, match="integrity_check"):
            run_backup(load_config(import_env.config_path), now=NOW)
        _assert_nothing_written(import_env)

    def test_integrity_problem_in_identity_copy_removes_catalog_copy(
        self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        _init()
        original = catalog_backup.integrity_problems
        calls = {"n": 0}

        def second_fails(path: Path) -> int:
            calls["n"] += 1
            return 1 if calls["n"] == 2 else original(path)

        monkeypatch.setattr(catalog_backup, "integrity_problems", second_fails)
        with pytest.raises(ValueError):
            run_backup(load_config(import_env.config_path), now=NOW)
        assert calls["n"] == 2
        _assert_nothing_written(import_env)

    def test_copy_with_old_version_leaves_nothing(
        self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        _init()
        monkeypatch.setattr(catalog_backup, "user_version", lambda path: 0)
        with pytest.raises(ValueError):
            run_backup(load_config(import_env.config_path), now=NOW)
        _assert_nothing_written(import_env)

    def test_sqlite_error_during_copy_leaves_nothing(
        self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        _init()
        original = catalog_backup.copy_database
        calls = {"n": 0}

        def second_fails(source: Path, target: Path) -> None:
            calls["n"] += 1
            if calls["n"] == 2:
                raise sqlite3.OperationalError("database is locked")
            original(source, target)

        monkeypatch.setattr(catalog_backup, "copy_database", second_fails)
        with pytest.raises(ValueError) as excinfo:
            run_backup(load_config(import_env.config_path), now=NOW)
        assert "\n" not in str(excinfo.value)
        _assert_no_paths(str(excinfo.value), import_env)
        _assert_nothing_written(import_env)


class TestBackupCommand:
    def test_prints_names_and_counts_without_paths(
        self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str]
    ) -> None:
        _init()
        run_import(load_config(import_env.config_path), import_env.source_name)
        capsys.readouterr()
        assert main(["backup"]) == 0
        captured = capsys.readouterr()
        assert captured.err == ""
        out = captured.out
        catalog_names = _files(_catalog_backups(import_env))
        identity_names = _files(_identity_backups(import_env))
        assert len(catalog_names) == len(identity_names) == 1
        assert catalog_names[0] in out
        assert identity_names[0] in out
        # 2 つの複製の日時は同じ
        assert catalog_names[0].removeprefix("catalog-") == identity_names[0].removeprefix(
            "identity-"
        )
        assert "撮影 18 件" in out
        assert "行 7 件" in out
        assert "ok" in out
        _assert_no_paths(out, import_env)

    def test_same_name_exits_1_with_one_line(
        self,
        import_env: ImportEnv,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        _init()
        monkeypatch.setattr(backup, "backup_timestamp", lambda now=None: STAMP)
        assert main(["backup"]) == 0
        capsys.readouterr()
        assert main(["backup"]) == 1
        captured = capsys.readouterr()
        assert captured.out == ""
        assert len(captured.err.strip().splitlines()) == 1
        assert captured.err.startswith("エラー: ")
        assert "Traceback" not in captured.err
        _assert_no_paths(captured.err, import_env)

    def test_before_init_exits_1_with_one_line(
        self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str]
    ) -> None:
        assert main(["backup"]) == 1
        captured = capsys.readouterr()
        assert len(captured.err.strip().splitlines()) == 1
        assert "Traceback" not in captured.err
        _assert_no_paths(captured.err, import_env)

    def test_unknown_argument_exits_2(self, import_env: ImportEnv) -> None:
        with pytest.raises(SystemExit) as excinfo:
            main(["backup", "--to", "x"])
        assert excinfo.value.code == 2