"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2). 틀: 정본 = DB `master_base_price`(data = 지금 값 · seed = 초기값) · 원본 파일 `resources/data_*` 는 **주입 재료로 읽기만** · [저장] 한 번 = {base_revision, edits, added, deleted} → 판이 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · 줄마다 changed_columns(초기값과 다른 칸)·is_added(관리자 추가 줄) · [초기값으로] = data := seed(추가 줄은 지움). 기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜. DB 는 `helper_z01_fake_repo` 가짜로 — 저장소 SQL 흐름(잠금·되돌림)은 맨 아래 가짜 커넥션으로 잼. """ from __future__ import annotations import asyncio import hashlib import json from decimal import Decimal from pathlib import Path from types import SimpleNamespace import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from helper_z01_fake_repo import FakeRepo, install from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo from Z01_MasterData import Z01_MasterData_Router as router_module ROOT = Path(__file__).resolve().parents[2] SOURCES = [ ROOT / "resources/data_cost_input_value" / name for name in ( "labor_const_2026-01-01.json", "labor_mfg_2026-07-01.json", "mach_base_2026.json", "mat_price_public_2026-08-14.json", "oil_2026-08-14.json", "oil_regional_2026-09-09.json", "rates_2026.json", ) ] @pytest.fixture def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo: return install(monkeypatch) @pytest.fixture def client(fake: FakeRepo) -> TestClient: from common_util.common_util_auth import verify_session app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "SYSTEM_ADMIN"} return TestClient(app) def _get(client: TestClient, kind: str, **params) -> dict: res = client.get(f"/api/master-data/base-prices/{kind}", params=params) assert res.status_code == 200, res.text return res.json() def _row(client: TestClient, kind: str, row_id: str) -> dict: rows = _get(client, kind, q=row_id.rsplit("/", 1)[-1], size=500)["rows"] return next(r for r in rows if r["@id"] == row_id) def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None): if base is None: base = _get(client, kind, size=1)["revision"] body = { "base_revision": base, "edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits], "added": [{"data": d} for d in added], "deleted": list(deleted), } return client.put(f"/api/master-data/base-prices/{kind}", json=body) def _edit(client: TestClient, kind: str, row_id: str, column: str, value): return _save(client, kind, edits=[(row_id, column, value)]) def _sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> None: expected = { "labor": (261, ["daily_wage_krw"]), "material": (6999, ["price_krw"]), "oil": (36, ["price_krw_per_l"]), "machine": (613, ["price_thousand_krw", "loss_coefficient_per_hour"]), } for kind, (total, editable) in expected.items(): table = _get(client, kind, size=500) assert table["total"] == total and table["editable"] == editable, kind assert table["status"] == "success" and table["revision"] == 0, kind ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]] assert len(ids) == len(set(ids)), kind # 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감 assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"]) assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")} columns = {c["key"] for c in table["columns"]} assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖 assert not columns & {"changed_columns", "is_added"}, kind assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" assert _row(client, "oil", "national_average/oil_diesel")["price_krw_per_l"] == 1846.39 assert _row(client, "oil", "regional/oil_diesel/01")["region_name"] == "서울" rate = _get(client, "rate", size=500) # 2026-09-18 218 → 220 — 전문공사 일반관리비 300억~1000억·1000억 이상 두 구간이 빠져 있던 것을 채움 assert rate["total"] == 220 and "rate_percent" in rate["editable"] by_id = {r["@id"]: r for r in rate["rows"]} assert by_id["rate_sanjae"]["rate_percent"] == 3.56 goyong = ( "rate_goyong/brackets/estimated_amount_bracket=gte_140_billion;grade=1" # 구간 칸 이름 차례 ) assert by_id[goyong]["rate_percent"] == 1.57 and by_id[goyong]["base"] == "total_labor_cost" material = _get(client, "material", q="육각볼트", size=5) assert 0 < material["total"] < 6999 and len(material["rows"]) == 5 def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를_실음(client: TestClient) -> None: table = _get(client, "machine", q="0101-0007", size=5) assert "hourly_total_krw" in table["locked"] and "hourly_total_krw" in table["formula"] row = next(r for r in table["rows"] if r["@id"] == "0101-0007") parts = row["hourly_loss_krw"] + row["hourly_fuel_krw"] + row["hourly_operator_krw"] assert row["hourly_total_krw"] == pytest.approx(parts) assert ( "hourly_total_krw" in row["@formula"] and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"] ) res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1) assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"] def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: from B09_Estimation.B09_Estimation_MachineOperating import ( hourly_cost_of, load_operating_records, ) codes = sorted(r.machine_code for r in load_operating_records().records)[::9] rows = {r["@id"]: r for r in _get(client, "machine", size=500, page=1)["rows"]} rows |= {r["@id"]: r for r in _get(client, "machine", size=500, page=2)["rows"]} checked = 0 from B09_Estimation.B09_Estimation_MachineCost import MachineCostError for code in codes: try: cost = hourly_cost_of(code) except MachineCostError: # 손료계수 없는 기종 — B09 도 못 셈 assert rows[code]["hourly_total_krw"] is None and rows[code]["hourly_note"], code continue if cost.gaps: assert rows[code]["hourly_total_krw"] is None, code # 반만 선 값은 안 냄 continue assert rows[code]["hourly_total_krw"] == pytest.approx(float(cost.money.total)), code checked += 1 assert checked >= 5 def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None: before = {p: _sha(p) for p in SOURCES} res = _save( client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 230000)], base=0, ) assert res.status_code == 200, res.text assert res.json() == {"status": "success", "revision": 1, "added": []} row = _row(client, "labor", "labor_const/1001") assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"] stored = fake.by_key("labor")["labor_const/1001"] assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907 assert fake.saved_by == [42] # 누가 고쳤나 assert _get(client, "labor", size=1)["revision"] == 1 assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다 assert {p: _sha(p) for p in SOURCES} == before def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClient) -> None: base = _row(client, "machine", "0101-0007") operator = base["operator_occupation_code"] wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] assert _edit( client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000 ).is_success assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success price = base["price_thousand_krw"] assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success after = _row(client, "machine", "0101-0007") assert after["hourly_operator_krw"] > base["hourly_operator_krw"] assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"] assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2) # 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜 assert after["changed_columns"] == ["price_thousand_krw"] def test_계산_칸은_DB_에_안_씀(client: TestClient, fake: FakeRepo) -> None: """기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴.""" _edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001) data = fake.by_key("machine")["0101-0007"]["data"] assert data["loss_coefficient_per_hour"] == 0.001 assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data) def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None: good = ("labor_const/1001", "daily_wage_krw", 230000) for bad, status in ( (("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸 (("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값 (("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄 ): res = _save(client, "labor", edits=[good, bad]) assert res.status_code == status, (bad, res.text) assert res.json()["status"] == "error" res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"]) assert res.status_code == 404 assert fake.revisions == {} and fake.saved_by == [] assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 def test_낡은_판으로_저장하면_409( client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch ) -> None: assert _save( client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0 ).is_success stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"] assert _row(client, "labor", "labor_const/1002")["changed_columns"] == [] # 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄) async def old_revision(kind: str) -> int: return 0 monkeypatch.setattr(repo, "revision", old_revision) raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0) assert raced.status_code == 409 and fake.revisions["labor"] == 1 def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None: data = {"specification": "ZZ시험 규격, 특수", "unit": "개", "price_krw": 1234} res = _save(client, "material", added=[data]) assert res.status_code == 200, res.text (key,) = res.json()["added"] assert key.startswith("added/") and res.json()["revision"] == 1 table = _get(client, "material", size=1, page=7000) assert table["total"] == 7000 row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례) assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, []) assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수" assert _get(client, "material", q="ZZ시험")["total"] == 1 # 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침 assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success assert _edit(client, "material", "10023392", "specification", "x").status_code == 400 for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}): assert _save(client, "material", added=[bad]).status_code == 400, bad assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄 gone = _save(client, "material", deleted=[key, "10023392"]) assert gone.status_code == 200 ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]} assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998 assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게) def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None: _edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000) added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}]) (key,) = added.json()["added"] _save(client, "labor", deleted=["labor_const/1002"]) res = client.post( "/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]}, ) assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4} back = _row(client, "labor", "labor_const/1001") assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == [] assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림 assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움 missing = client.post( "/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]} ) assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"] empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []}) assert empty.status_code == 422 # 통째로 되돌리기 막음 def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: rate = _get(client, "rate", size=1) assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"]) for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로 notice = _get(client, kind, size=1)["notice"] assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind assert len(rate["notice"]) == 2 and len(_get(client, "material", size=1)["notice"]) == 1 def test_막는_자리(client: TestClient) -> None: assert _save(client, "wage", base=0).status_code == 404 assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫 locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸 assert locked.status_code == 400 and "공표 원문" in locked.json()["message"] for bad in (True, -1, "220000", float("nan")): body = { "base_revision": 0, "edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}], } res = client.put( "/api/master-data/base-prices/labor", content=json.dumps(body).encode(), headers={"content-type": "application/json"}, ) assert res.status_code in (400, 422), bad fee = next( r for r in _get(client, "rate", size=500)["rows"] if r["@id"].startswith("rate_performance_guarantee_fee/brackets/") ) assert ( _edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400 ) # 그 줄엔 요율 칸 없음 assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56") many = [("labor_const/1001", "daily_wage_krw", 1)] * 501 assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지 extra = client.put( "/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}} ) assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함) old = client.put( "/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}} ) assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐 assert client.get("/api/master-data/overrides").status_code == 404 def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None: """브레인 ① — 노임 두 판(건설 01-01 · 제조 07-01)·유가 두 판(전국 08-14 · 지역 09-09)이 한 표에 섞임.""" assert _row(client, "labor", "labor_const/1001")["effective_date"] == "2026-01-01" assert _row(client, "labor", "labor_mfg/1")["effective_date"] == "2026-07-01" assert _row(client, "oil", "national_average/oil_diesel")["effective_date"] == "2026-08-14" assert _row(client, "oil", "regional/oil_diesel/01")["effective_date"] == "2026-09-09" assert _row(client, "material", "10023392")["effective_date"] == "2026-08-14" assert _row(client, "rate", "rate_sanjae")["effective_date"] == "2026-04-13" m = _row(client, "machine", "0101-0007") assert (m["effective_date"], m["operating_effective_date"]) == ("2026-01-01", "2026-01-01") assert (m["fuel_price_date"], m["operator_wage_date"]) == ("2026-08-14", "2026-01-01") no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_type"] is None) assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None SOURCE_KEYS = { "source_id", "name", "publisher", "where", "cycle", "latest_published", "our_edition", "checked_at", "outdated", } def test_표마다_출처와_뒤처짐을_서버가_판정(client: TestClient) -> None: """브레인 — 건설 노임이 한 판 뒤처진 것을 반 년간 아무도 몰랐음 → 표 수준에 출처 · outdated 는 서버 판정.""" labor = {s["source_id"]: s for s in _get(client, "labor", size=1)["source"]} const = labor["labor_const"] assert set(const) == SOURCE_KEYS assert (const["our_edition"], const["latest_published"], const["outdated"]) == ( "2026-01-01", "2026-09-01", True, ) assert "cak.or.kr" in const["where"] and const["publisher"] == "대한건설협회" assert const["checked_at"] == "2026-09-15" assert labor["labor_mfg"]["outdated"] is None # 최신 공표일 모름 machine = {s["source_id"]: s for s in _get(client, "machine", size=1)["source"]} assert ( machine["machine_operating"]["our_edition"] == "2026-01-01" ) # derived_from.effective_date assert machine["mach_base"]["outdated"] is False assert [s["source_id"] for s in _get(client, "oil", size=1)["source"]] == [ "oil", "oil_regional", ] def test_우리_판은_출처표_글자가_아니라_파일에서_읽음( client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from Z01_MasterData import Z01_MasterData_BasePrices as base_prices real = sorted((ROOT / "resources/data_master_sources").glob("sources_*.json"))[-1] doc = json.loads(real.read_text(encoding="utf-8")) for s in doc["sources"]: if s["source_id"] == "rates": # 표 글자가 굳어 틀려도 s.update(our_edition="1999-01-01", latest_published="2027-01-01", status="current") folder = tmp_path / "sources" folder.mkdir() (folder / "sources_2099-01-01.json").write_text(json.dumps(doc), encoding="utf-8") monkeypatch.setattr(base_prices, "SOURCES_DIR", folder) rate = _get(client, "rate", size=1)["source"] assert [(s["our_edition"], s["outdated"]) for s in rate] == [("2026-04-13", True)] monkeypatch.setattr(base_prices, "SOURCES_DIR", tmp_path / "없음") assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로) def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: """브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본.""" labels = json.loads( (ROOT / "resources/data_master_labels/labels_2026-01-01.json").read_text(encoding="utf-8") ) merged = {t["key"]: t for t in labels["merged_tables"]} for kind in ("labor", "material", "oil"): named = {c["key"]: c for c in merged[kind]["columns"]} for column in _get(client, kind, size=1)["columns"]: if column["key"] in named: assert column["label"] == named[column["key"]]["name_ko"], (kind, column["key"]) assert column["unit"] == named[column["key"]]["unit"] assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], ( kind ) def _sorted_rows(client: TestClient, kind: str, column: str, desc: int = 0, **params) -> list: table = _get(client, kind, sort=column, desc=desc, size=500, **params) return [r.get(column) for r in table["rows"]] def test_차례_세우기는_서버가_전체_줄로(client: TestClient) -> None: """브레인 — 자재 140 쪽이라 화면이 받은 50 줄만 세우면 1 쪽 안에서만 맞는 가짜 차례.""" table = _get(client, "material", size=1) assert set(table["sortable"]) == {c["key"] for c in table["columns"]} first = _get(client, "material", sort="price_krw", size=1) last = _get(client, "material", sort="price_krw", desc=1, size=1) assert first["total"] == last["total"] == 6999 assert first["rows"][0]["price_krw"] < last["rows"][0]["price_krw"] page2 = _get(client, "material", sort="price_krw", size=50, page=2)["rows"] assert first["rows"][0]["price_krw"] <= page2[0]["price_krw"] # 쪽을 넘어도 이어짐 values = _sorted_rows(client, "material", "price_krw") assert values == sorted(values) def test_거른_뒤에_세움_모르는_열은_거절(client: TestClient) -> None: hits = _get(client, "material", q="육각볼트", sort="price_krw", desc=1, size=500) values = [r["price_krw"] for r in hits["rows"]] assert values == sorted(values, reverse=True) and hits["total"] == len(values) assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in hits["rows"]) bad = client.get("/api/master-data/base-prices/material", params={"sort": "없는열"}) assert bad.status_code == 400 and "없는열" in bad.text # 조용히 원본 차례로 안 돌아감 def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None: """미공표 노임 14 · 손료계수 없는 기계 226 이 첫 쪽을 채우면 표를 못 씀.""" for desc in (0, 1): wages = _sorted_rows(client, "labor", "daily_wage_krw", desc) filled = [v for v in wages if v is not None] assert wages[: len(filled)] == sorted(filled, reverse=bool(desc)) assert wages[len(filled) :] == [None] * (len(wages) - len(filled)) and len(filled) < len( wages ) hourly = _sorted_rows(client, "machine", "hourly_total_krw", 1) assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐 def test_고친_값으로_세움(client: TestClient) -> None: _edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000) top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0] assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000 def test_sort_가_없으면_원본_차례(client: TestClient) -> None: assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae" assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None: """카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄.""" data = { "machine_name": "시험 굴삭기", "price_thousand_krw": 100000, "loss_coefficient_per_hour": 0.0001, } (key,) = _save(client, "machine", added=[data]).json()["added"] row = _get(client, "machine", size=1, page=614)["rows"][0] assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000) assert row["hourly_total_krw"] is None and row["hourly_note"] assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸 # ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ────────────────────────────── class _Cursor: def __init__(self, db: SimpleNamespace) -> None: self.db = db async def __aenter__(self): return self async def __aexit__(self, *_): return False async def execute(self, sql: str, params=()) -> None: self.db.sql.append(" ".join(sql.split())) async def fetchone(self): return (self.db.revision,) async def fetchall(self): return [(key,) for key in self.db.keys] class _Connection: def __init__(self, db: SimpleNamespace) -> None: self.db = db def cursor(self, *_): return _Cursor(self.db) async def begin(self) -> None: self.db.log.append("begin") async def commit(self) -> None: self.db.log.append("commit") async def rollback(self) -> None: self.db.log.append("rollback") class _Pool: def __init__(self, db: SimpleNamespace) -> None: self.db = db def acquire(self): return self async def __aenter__(self): return _Connection(self.db) async def __aexit__(self, *_): return False def _writes(db: SimpleNamespace) -> list[str]: return [ s for s in db.sql if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price")) ] def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None: db = SimpleNamespace(sql=[], log=[], revision=4, keys=[]) monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) with pytest.raises(repo.RevisionConflict): asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7)) assert db.log == ["begin", "rollback"] and not _writes(db) db.sql.clear() db.log.clear() assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5 assert db.log == ["begin", "commit"] lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE")) bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision")) writes = [db.sql.index(s) for s in _writes(db)] assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1 assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만 def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None: db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"]) monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db)) with pytest.raises(repo.UnknownRows): asyncio.run(repo.reset("labor", ["a", "없음"], 7)) assert db.log == ["begin", "rollback"] and not _writes(db) db.sql.clear() db.log.clear() assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3 assert db.log == ["begin", "commit"] assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "]