"""PLAN 472-6 6-A — [저장] · [확정] 한 본문 창구의 원자성. 합성 repository와 ``tmp_path`` storage만 사용해 중간 실패 rollback, 복구 실패 표시, 정상 commit, 같은 프로젝트 동시 저장 직렬화를 지킨다. """ from __future__ import annotations import asyncio import copy import json import sys from dataclasses import dataclass from pathlib import Path from uuid import UUID import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) from B06_Section import B06_Section_Router_DesignSave as design # noqa: E402 from B06_Section.B06_Section_Router_DesignSave_Route import StepFailed # noqa: E402 from common_util import common_util_save_guard as guard # noqa: E402 PROJECT = UUID("00000000-0000-0000-0000-000000000472") def test_storage_되돌림은_바뀐_것만(tmp_path: Path) -> None: (tmp_path / "B06_Section" / "a").mkdir(parents=True) keep = tmp_path / "B06_Section" / "a" / "keep.json" edit = tmp_path / "B06_Section" / "edit.json" gone = tmp_path / "workflow.json" for path, text in ((keep, "k"), (edit, "e"), (gone, "w")): path.write_text(text, encoding="utf-8") outside = tmp_path / "B03_FileInput.txt" outside.write_text("o", encoding="utf-8") snap = guard.snapshot_storage(tmp_path, ("B06_Section", "workflow.json")) kept_stamp = keep.stat().st_mtime_ns edit.write_text("changed", encoding="utf-8") gone.unlink() (tmp_path / "B06_Section" / "new.json").write_text("n", encoding="utf-8") outside.write_text("o2", encoding="utf-8") assert snap.changed() == ["B06_Section/edit.json", "B06_Section/new.json", "workflow.json"] assert snap.restore() == 3 assert edit.read_text(encoding="utf-8") == "e" and gone.read_text(encoding="utf-8") == "w" assert not (tmp_path / "B06_Section" / "new.json").exists() assert keep.stat().st_mtime_ns == kept_stamp and outside.read_text(encoding="utf-8") == "o2" snap.discard() assert not snap.copy_dir.exists() class _Acquire: def __init__(self, connection: object) -> None: self.connection = connection async def __aenter__(self) -> object: return self.connection async def __aexit__(self, *_args) -> None: return None class _Pool: def __init__(self, connection: object) -> None: self.connection = connection def acquire(self) -> _Acquire: return _Acquire(self.connection) @dataclass class FakeRepository: root: Path rows: dict[str, list[dict]] @property def state_file(self) -> Path: return self.root / "B06_Section" / "state.json" @pytest.fixture def repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FakeRepository: repo = FakeRepository(tmp_path, {"routes": [{"id": 7, "value": "before"}]}) repo.state_file.parent.mkdir(parents=True) repo.state_file.write_text('{"value":"before"}', encoding="utf-8") pool = _Pool(repo) async def stored_path(_connection, _project_id): return "synthetic" async def snapshot_rows(_connection, _project_id, surface=False): assert surface is False return copy.deepcopy(repo.rows) async def restore_rows(_connection, _project_id, saved): repo.rows = copy.deepcopy(saved) return ["routes"] monkeypatch.setattr(design, "get_db_pool", lambda: pool) monkeypatch.setattr(design, "get_project_storage_relative_path", stored_path) monkeypatch.setattr(design, "resolve_stored_project_path", lambda _stored: repo.root) monkeypatch.setattr(guard, "snapshot_rows", snapshot_rows) monkeypatch.setattr(guard, "restore_rows", restore_rows) guard._LOCKS.pop(str(PROJECT), None) return repo def _request() -> design.DesignSaveRequest: return design.DesignSaveRequest(route_id=7) def _body(response) -> dict: return json.loads(bytes(response.body).decode("utf-8")) def test_중간_차례_실패면_repository_storage_그대로( repository: FakeRepository, monkeypatch: pytest.MonkeyPatch ) -> None: before_rows = copy.deepcopy(repository.rows) before_file = repository.state_file.read_bytes() async def broken(_project_id, _request, _session): repository.rows["routes"][0]["value"] = "half" repository.state_file.write_text('{"value":"half"}', encoding="utf-8") raise StepFailed("plusStations", "일부러 실패") monkeypatch.setattr(design, "_run_steps", broken) response = asyncio.run(design.save_design(PROJECT, _request(), None)) body = _body(response) assert response.status_code == 409 assert body["step"] == "plusStations" and body["restored"] is True assert "일부러 실패" in body["message"] assert repository.rows == before_rows and repository.state_file.read_bytes() == before_file def test_정상_한_본문은_모든_차례를_commit( repository: FakeRepository, monkeypatch: pytest.MonkeyPatch ) -> None: async def complete(_project_id, _request, _session): repository.rows["routes"][0]["value"] = "after" repository.state_file.write_text('{"value":"after"}', encoding="utf-8") return {name: {"status": "success"} for name in ("settings", "plusStations", "cross")} monkeypatch.setattr(design, "_run_steps", complete) response = asyncio.run(design.save_design(PROJECT, _request(), None)) body = _body(response) assert response.status_code == 200 and body["status"] == "success" assert set(body["steps"]) == {"settings", "plusStations", "cross"} assert repository.rows["routes"][0]["value"] == "after" assert json.loads(repository.state_file.read_text(encoding="utf-8"))["value"] == "after" def test_되돌림마저_실패면_복구_확인_필요( repository: FakeRepository, monkeypatch: pytest.MonkeyPatch ) -> None: async def broken(_project_id, _request, _session): repository.state_file.write_text("broken", encoding="utf-8") raise StepFailed("settings", "설정 실패") def no_restore(self): raise OSError("디스크 잠김") monkeypatch.setattr(design, "_run_steps", broken) monkeypatch.setattr(guard.StorageSnapshot, "restore", no_restore) response = asyncio.run(design.save_design(PROJECT, _request(), None)) body = _body(response) assert response.status_code == 500 assert body["restored"] is False and body["step"] == "settings" assert "복구 확인 필요" in body["message"] def test_같은_프로젝트_저장이_겹치면_뒤_요청이_기다림( repository: FakeRepository, monkeypatch: pytest.MonkeyPatch ) -> None: async def run() -> tuple[dict, dict]: entered = asyncio.Event() release = asyncio.Event() calls = 0 async def steps(_project_id, _request, _session): nonlocal calls calls += 1 if calls == 1: entered.set() await release.wait() return {} monkeypatch.setattr(design, "_run_steps", steps) first = asyncio.create_task(design.save_design(PROJECT, _request(), None)) await entered.wait() second = asyncio.create_task(design.save_design(PROJECT, _request(), None)) await asyncio.sleep(0) assert not second.done() release.set() return _body(await first), _body(await second) first, second = asyncio.run(run()) assert first["status"] == second["status"] == "success" assert first["waited"] is False and second["waited"] is True