Files
Aislo/resources/tester/test_z01_work_item_edit.py
T
eomsangdonandClaude Opus 5 e1c71c3e8b feat(Z01): 공종 편집 저장소·API — 목록·상세·전체 줄 저장·초기값으로 (PLAN 1-3)
- GET /work-items(상태별 개수) · GET/PUT /work-items/{key} · POST …/reset 신설, 시스템관리자 전용 묶음에 등록
- 저장은 한 트랜잭션 — 낡은 revision 409 · 없는 자원 열쇠·남의 줄 id 422 · 실패 시 전부 되돌림
- 빠진 줄은 초기값 줄이면 숨김(초기값으로 때 살림) · 관리자 추가 줄이면 지움
- 검사·줄 비교·되돌리기 규칙은 WorkItems.py 순수 함수 · SQL 은 Repository_WorkItems.py
- 옛 기초단가 표의 공종 축 보기(JSON 읽기)는 그대로 둠 — BasePrices.py(sub_laptop_2 몫)가 동기로 부름
- 시험 15개(test_z01_work_item_edit.py) — DB 없이 가짜 커넥션으로 409·422·롤백 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-18 23:47:33 +09:00

236 lines
9.1 KiB
Python

"""Z01 공종 편집 — 규칙(순수 함수) · 요청 모델 · 저장 흐름(409·422·전부 아니면 전무) · 등록.
(2026-09-18 브레인 PLAN 1-0 계약 · sub_laptop_3) DB 없이 — 저장 흐름은 가짜 커넥션·저장소로 훑음.
"""
from __future__ import annotations
import importlib
from contextlib import asynccontextmanager
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from pydantic import ValidationError
from common_util.common_util_auth import require_system_admin, verify_session
from Z01_MasterData import Z01_MasterData_Router_WorkItems as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
SEED_ROW = {"resource_kind": "labor", "resource_key": "L-001", "amount": 1.5, "sort_order": 0}
def _row(row_id, seed=SEED_ROW, **values):
base = {"id": row_id, "variant": "", "resource_kind": "labor", "resource_key": "L-001"}
return {**base, "amount": Decimal("1.500000"), "sort_order": 0, "seed": seed, **values}
# ── 규칙 ──
def test_고친_칸은_초기값과_값으로_비교함() -> None:
assert work_items.changed_columns(_row(1), SEED_ROW, work_items.ROW_FIELDS) == []
changed = _row(1, amount=Decimal("2"))
assert work_items.changed_columns(changed, SEED_ROW, work_items.ROW_FIELDS) == ["amount"]
assert work_items.changed_columns(_row(1, seed=None), None, work_items.ROW_FIELDS) == []
def test_검사는_남의_줄_겹친_줄_없는_자원을_막음() -> None:
rows = [
{"id": 1, "resource_key": "L-001"},
{"id": 1, "resource_key": None},
{"id": 99, "resource_key": None},
{"id": None, "resource_key": "없는열쇠"},
]
errors = work_items.check_rows(rows, existing_ids={1, 2}, known_keys={"L-001"})
assert len(errors) == 3
assert "두 번" in errors[0] and "id 99" in errors[1] and "없는열쇠" in errors[2]
assert work_items.check_rows(rows[:1], {1}, {"L-001"}) == []
def test_전체_줄_보내기는_바뀐_줄만_쓰고_빠진_줄은_숨기거나_지움() -> None:
existing = [_row(1), _row(2, sort_order=1), _row(3, seed=None, sort_order=2)]
same = {f: existing[0].get(f) for f in work_items.ROW_FIELDS} | {"id": 1, "amount": 1.5}
new = {"id": None, "resource_kind": "material", "resource_key": None, "amount": None}
plan = work_items.plan_rows(existing, [same, new])
assert plan["updates"] == [] # 1.500000 = 1.5 · 순서도 그대로
assert plan["inserts"][0]["resource_kind"] == "material"
assert plan["inserts"][0]["sort_order"] == 1
assert plan["hide"] == [2] # 초기값 줄 — 숨김(되살릴 수 있게)
assert plan["drop"] == [3] # 관리자 추가 줄 — 지움
moved = work_items.plan_rows(existing[:2], [{**same, "id": 2}, same])
assert [u["id"] for u in moved["updates"]] == [2, 1] # 순서만 바뀌어도 sort_order 를 씀
def test_초기값으로는_seed_칸만_살리고_추가_줄은_지움() -> None:
head = {"id": 7, "name": "고친 이름", "seed": {"name": "원 이름", "status": "partial"}}
rows = [_row(1, amount=Decimal("9")), _row(2, is_deleted=1), _row(3, seed=None)]
plan = work_items.reset_plan(head, rows)
assert plan["head"] == {"name": "원 이름", "status": "partial"}
assert [u["id"] for u in plan["updates"]] == [1, 2]
assert plan["updates"][0]["amount"] == 1.5
assert plan["drop"] == [3] and plan["inserts"] == [] and plan["hide"] == []
def test_상세는_seed_를_안_내보내고_고친_칸만_알림() -> None:
head = {"id": 7, "work_item_key": "FW-00001", "name": "새", "status": "partial"}
head |= {"seed": {"name": "옛"}, "source": [{"pum_table_id": "T1"}], "notes": ["[주] 1"]}
out = work_items.detail(head, [_row(1), _row(2, seed=None)], revision=4)
assert out["item"]["revision"] == 4 and out["item"]["changed_columns"] == ["name"]
assert "seed" not in out["item"] and "id" not in out["item"]
assert out["source"] == [{"pum_table_id": "T1"}] and out["notes"] == ["[주] 1"]
assert [r["is_added"] for r in out["rows"]] == [False, True]
assert out["rows"][0]["id"] == 1 and "seed" not in out["rows"][0]
# ── 요청 모델 ──
def _body(**row):
item = {"name": "공종", "status": "reviewed"}
return {"base_revision": 3, "item": item, "rows": [{"resource_kind": "labor", **row}]}
@pytest.mark.parametrize(
"row",
[{"amount": -1}, {"resource_key": ""}, {"resource_kind": "oil"}, {"unknown": 1}],
)
def test_요청_모델은_음수_빈열쇠_모르는_종류_모르는_칸을_막음(row) -> None:
with pytest.raises(ValidationError):
router_module.WorkItemSave.model_validate(_body(**row))
# ── 저장 흐름(가짜 커넥션) ──
class _Connection:
def __init__(self) -> None:
self.log: list[str] = []
async def begin(self) -> None:
self.log.append("begin")
async def commit(self) -> None:
self.log.append("commit")
async def rollback(self) -> None:
self.log.append("rollback")
@pytest.fixture
def client(monkeypatch):
connection = _Connection()
class _Pool:
@asynccontextmanager
async def acquire(self):
yield connection
head = {"id": 7, "work_item_key": "FW-00001", "name": "공종", "status": "partial"}
head |= {"seed": {"name": "공종"}, "source": [], "notes": []}
async def fetch_item(_c, key, lock=False):
return dict(head) if key == "FW-00001" else None
async def fetch_rows(_c, _item_id, with_deleted=False):
return [_row(1)]
async def revision(_c, _scope, lock=False):
return 3
async def known_resource_keys(_c, keys):
return keys & {"L-001"}
async def write(_c, *args):
connection.log.append("write")
async def bump_revision(_c, _scope):
connection.log.append("bump")
async def list_items(_c, **picked):
connection.log.append(picked["status"])
return {"items": [], "total": 2, "counts": {"partial": 2}}
monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool())
for name, fake in {
"list_items": list_items,
"fetch_item": fetch_item,
"fetch_rows": fetch_rows,
"revision": revision,
"known_resource_keys": known_resource_keys,
"write": write,
"bump_revision": bump_revision,
}.items():
monkeypatch.setattr(router_module.repo, name, fake)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42}
test_client = TestClient(app)
test_client.log = connection.log
return test_client
URL = "/api/master-data/work-items/FW-00001"
def test_낡은_revision_은_409_이고_아무것도_안_씀(client) -> None:
response = client.put(URL, json={**_body(id=1, resource_key="L-001"), "base_revision": 2})
assert response.status_code == 409
assert client.log == ["begin", "rollback"]
def test_없는_자원_열쇠는_422_이고_아무것도_안_씀(client) -> None:
response = client.put(URL, json=_body(resource_key="없는열쇠"))
assert response.status_code == 422 and "없는열쇠" in response.json()["detail"]
assert client.log == ["begin", "rollback"]
def test_맞는_저장은_쓰고_revision_올리고_새_상세를_줌(client) -> None:
response = client.put(URL, json=_body(id=1, resource_key="L-001", amount=2))
assert response.status_code == 200, response.text
assert client.log == ["begin", "write", "bump", "commit"]
assert response.json()["status"] == "success" and response.json()["item"]["revision"] == 3
def test_목록은_상태별_개수를_다_채우고_모르는_상태는_막음(client) -> None:
body = client.get("/api/master-data/work-items?status=").json()
assert body["counts"] == {s: 2 if s == "partial" else 0 for s in work_items.STATUSES}
assert client.get("/api/master-data/work-items?status=needs_input").status_code == 200
assert client.get("/api/master-data/work-items?status=bogus").status_code == 422
assert client.log == ["", "needs_input"]
def test_초기값으로와_없는_공종(client) -> None:
assert client.post(URL + "/reset").status_code == 200
assert client.log == ["begin", "write", "bump", "commit"]
assert client.get("/api/master-data/work-items/FW-99999").status_code == 404
# ── 등록 ──
def _calls(dependant) -> set:
found = set()
for sub in dependant.dependencies:
found.add(sub.call)
found |= _calls(sub)
return found
def test_공종_편집_경로는_시스템_관리자만() -> None:
app = importlib.import_module("main").app
routes = {(r.path, m) for r in app.routes if isinstance(r, APIRoute) for m in r.methods}
wanted = {
("/api/master-data/work-items", "GET"),
("/api/master-data/work-items/{key}", "GET"),
("/api/master-data/work-items/{key}", "PUT"),
("/api/master-data/work-items/{key}/reset", "POST"),
}
assert wanted <= routes
for route in app.routes:
if isinstance(route, APIRoute) and route.path.startswith("/api/master-data/work-items"):
assert {verify_session, require_system_admin} <= _calls(route.dependant)