202 lines
7.4 KiB
Python
202 lines
7.4 KiB
Python
"""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
|