Files
Aislo/resources/tester/test_472_6_design_save.py
T

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