"""일위대가 조합 층(PLAN 27-2a · 27-2c) — 세 층 한 길 `/api/m02/layers/{layer}/combos`. 못박는 것 - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. 정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. """ from __future__ import annotations import json import shutil from pathlib import Path from typing import Any import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from config import config_system from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Source as source from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.test_m02_structure import write_sample_table A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 URL = "/api/m02/layers/{}/combos" @pytest.fixture() def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: system = tmp_path / "system" system.mkdir() write_sample_table(system) monkeypatch.setattr(store, "FOLDER", system) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) storage = tmp_path / "storage" storage.mkdir() monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} app = FastAPI() app.include_router(items_router.router) app.include_router(combos_router.router) app.dependency_overrides[verify_session] = lambda: session return {"client": TestClient(app), "session": session, "storage": storage} def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] def _combo(name: str, codes=(A,), **more) -> dict: return {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes], **more} def _new(client: TestClient, layer: str, combo: dict, want: int = 200, **params) -> dict: res = client.post(URL.format(layer), json={"combo": combo}, params=params) assert res.status_code == want, res.text return res.json() def _list(client: TestClient, layer: str, **params) -> dict: res = client.get(URL.format(layer), params=params) assert res.status_code == 200, res.text return res.json() def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict: body.setdefault("판", _list(client, layer)["판"]) res = client.post(URL.format(layer) + "/tree", json=body) assert res.status_code == want, res.text return res.json() # ── 키 · 자리 ───────────────────────────────────────────────────────── def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world): boss = _as(world, "ADMIN") one = _new(boss, "company", _combo("회사 하나")) assert one["키"] == "UC7-000001" and one["combo"]["소유"] == "현장" assert tuple(one["combo"]) == combo_store.mcb.COLS # 열 한 벌 · 마스터와 같음 file = world["storage"] / "7" / "templates" / combos.FILE assert json.loads(file.read_text(encoding="utf-8"))["다음"] == 2 me = _as(world) mine = _new(me, "personal", _combo("내 것", (A, B))) assert mine["키"] == "UP42-000001" assert (world["storage"] / "7" / "42" / "templates" / combos.FILE).is_file() gone = me.delete(f"{URL.format('personal')}/{mine['키']}", params={"판": mine["판"]}) assert gone.status_code == 200 assert _new(me, "personal", _combo("다음 것"))["키"] == "UP42-000002" got = me.get(f"{URL.format('personal')}/UP42-000002").json() assert [x["로직"] for x in got["줄"]] == [A] and got["blocked"] is False def test_권한_층_규칙(world): user = _as(world) _new(user, "system", _combo("마스터"), 403) _new(user, "company", _combo("회사"), 403) _new(user, "personal", _combo("남의 것"), 403, owner="7/43") assert user.get(URL.format("system")).status_code == 200 # 마스터 읽기 = 누구나 assert user.get(URL.format("company")).status_code == 200 # 같은 회사 읽기 assert user.get(URL.format("personal"), params={"owner": "8/9"}).status_code == 403 _as(world, "ADMIN") _new(user, "company", _combo("회사")) world["session"]["company_id"] = None assert user.post(URL.format("personal"), json={"combo": _combo("x")}).status_code == 409 def test_검사_빈_조합_이름_겹침_본뜬키_판(world): me = _as(world) got = _new(me, "personal", _combo("빈 것", ()), 422) assert any("빈 조합" in x for x in got["detail"]["errors"]) first = _new(me, "personal", _combo("원본")) assert "이미 있음" in _new(me, "personal", _combo("원본"), 422)["detail"]["errors"][0] _new(me, "personal", _combo("엉터리 계보", 본뜬키="UP42-999999"), 400) master_key = m01.read(combo_store.FILE)[0]["줄"][0]["키"] branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key)) assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보 url = f"{URL.format('personal')}/{first['키']}" assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409 now = _list(me, "personal")["판"] res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now}) assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B # ── 나무 ───────────────────────────────────────────────────────────── def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): me = _as(world) key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"] _tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기") _tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기") _tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째") _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기") _tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"]) got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"]) row = next(r for r in got["줄"] if r["키"] == key) assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기") got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축") assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]] assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축" _tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"]) got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[]) assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음 row = next(r for r in got["줄"] if r["키"] == key) assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") _new(me, "personal", _combo("다른 것")) got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" assert ( "이미 있음" in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] ) other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") _tree(me, "personal", op="delete", 키=other) got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world): me = _as(world) key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"] tree = me.post( "/api/m02/layers/personal/tree", json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)}, ).json() column = tree["알림"]["열"] use = me.post( f"/api/m02/layers/personal/structures/{column}/use-combo", json={"종류": "일위대가", "키": key}, ) assert use.status_code == 200, use.text got = _tree(me, "personal", 409, op="delete", 경로=["석축"]) assert got["detail"]["users"] == ["옹벽 찰쌓기"] res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]}) assert res.status_code == 409 assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음 def _판(client: TestClient) -> str: return client.get("/api/m02/layers/personal/tree").json()["판"] @pytest.fixture() def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: folder = tmp_path / "master" folder.mkdir() for path in m01.FOLDER.glob("*.json"): if not path.name.startswith("_") or path.name == m01.mk.BOOK.name: shutil.copy(path, folder / path.name) own = folder / combos.FILE # 정본 머리의 빈 폴더 목록(사용자 화면 편집)은 안 물려받음 data = json.loads(own.read_text(encoding="utf-8")) data.pop("폴더", None) own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") monkeypatch.setattr(m01, "FOLDER", folder) return folder def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): admin = _as(world, "SYSTEM_ADMIN") before = _list(admin, "system") first = before["줄"][0]["키"] got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" before = _list(admin, "system") old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음 assert [old] in before["폴더"] _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더") assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"] assert [old] not in got["폴더"] doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) assert ["빈 폴더"] in doc["폴더"] assert sum(r["구분"] == "시험 폴더" for r in doc["줄"]) == len( [r for r in before["줄"] if r["구분"] == old] ) assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") # ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): boss = _as(world, "ADMIN") uc = _new(boss, "company", _combo("회사 조합", (A, B)))["키"] me = _as(world) up = _new(me, "personal", _combo("내 조합"))["키"] all_ = me.get("/api/m02/combos/all").json() assert [g["layer"] for g in all_] == ["system", "company", "personal"] assert [r["키"] for r in all_[1]["줄"]] == [uc] and [r["키"] for r in all_[2]["줄"]] == [up] assert combos.find(uc)["이름"] == "회사 조합" and combos.find(up)["이름"] == "내 조합" assert combos.find("UP42-999999") is None and combos.find("엉터리") is None by_key = me.get(f"/api/m02/combos/key/{up}").json() assert (by_key["layer"], by_key["owner"], by_key["combo"]["키"]) == ("personal", "7/42", up) assert me.get(f"/api/m02/combos/key/{uc}").json()["owner"] == "7" assert me.get("/api/m02/combos/key/UP42-999999").status_code == 404 world["session"]["company_id"] = 8 # 다른 회사 사람 — 남의 회사 조합은 못 봄 assert me.get(f"/api/m02/combos/key/{uc}").status_code == 403 world["session"]["company_id"] = 7 column = me.post( "/api/m02/layers/personal/tree", json={"op": "add-item", "경로": [], "이름": "돌붙임", "단위": "m", "판": _판(me)}, ).json()["알림"]["열"] base = f"/api/m02/layers/personal/structures/{column}" for key in (uc, up): res = me.post(f"{base}/use-combo", json={"종류": "일위대가", "키": key}) assert res.status_code == 200 and res.json()["키"] == key lines = [{"로직": A}, {"로직": B, "메모": "장비"}] # 이 층에 같은 내용 없음 = 새로 made = me.post(f"{base}/combo", json={"본뜬키": uc, "담은로직": lines, "이름": "회사 것 고침"}) assert made.status_code == 200, made.text new = made.json()["키"] assert new == "UP42-000002" and combos.find(new)["본뜬키"] == uc folder = layers.personal_dir(7, 42) item = next(i for i in items_store.items(folder) if i["열"] == column) assert item["일위대가"] == new and item["names"]["일위대가"] == "회사 것 고침" assert source.labor_rows(new)["줄"]["키"] == new # 프로젝트 스냅샷 원본도 층 조합 _as(world, "ADMIN") company_col = boss.post( "/api/m02/layers/company/tree", json={ "op": "add-item", "경로": [], "이름": "회사 항목", "단위": "m", "판": boss.get("/api/m02/layers/company/tree").json()["판"], }, ).json()["알림"]["열"] res = boss.post( f"/api/m02/layers/company/structures/{company_col}/use-combo", json={"종류": "일위대가", "키": up}, ) assert res.status_code == 403 # 회사 항목은 개인 조합을 못 씀 def test_면적_알약이_개인_조합을_읽음(world): me = _as(world) up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"] got = me.get(f"{URL.format('personal')}/{up}/inputs").json() assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"] assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got tree = me.post( "/api/m02/layers/personal/tree", json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)}, ).json() column = tree["알림"]["열"] me.post( f"/api/m02/layers/personal/structures/{column}/use-combo", json={"종류": "일위대가", "키": up}, ) key = next(i["key"] for i in tree["items"] if i["열"] == column) pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"}) assert pills.status_code == 200, pills.text assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"] def test_담을_로직_읽기는_로그인이면_됨(world): user = _as(world) logics = user.get("/api/m02/logic-lib/logics").json()["logics"] assert A in {x["키"] for x in logics} assert user.get("/api/m02/logic-lib/logic", params={"key": A}).json()["logic"]["키"] == A assert user.get("/api/m02/logic-lib/logic", params={"key": "GF999999"}).status_code == 404 assert user.get("/api/m02/logic-lib/subs").json()["subs"] assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 # ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") res = client.post(URL.format(layer) + "/paste", json=body, params=params) assert res.status_code == want, res.text return res.json() def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): user = _as(world) master = _list(user, "system")["줄"][:3] keys = [r["키"] for r in master] got = _paste(user, "personal", {"from_layer": "system", "키": keys}) assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] rows = {r["키"]: r for r in got["줄"]} for pair, src in zip(got["새키"], master): one = rows[pair["새키"]] assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) stale = {"from_layer": "system", "키": keys, "판": "낡은판"} assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): admin = _as(world, "ADMIN") for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") body = {"from_layer": "personal", "폴더": [["석축"]]} got = _paste(admin, "company", body) assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} got = _paste(admin, "company", one) # 한 단 폴더는 들어감 assert ["다른 곳", "찰쌓기"] in got["폴더"] def test_여러_개_옮기기_지우기_한_번에(world): me = _as(world) keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") 판 = _list(me, "personal")["판"] ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) assert res.status_code == 200, res.text assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 판 = _list(me, "personal")["판"] ops = [{"op": "delete", "키": k} for k in keys[1:]] got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] stale = {"판": "낡은판", "ops": ops} assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): admin = _as(world, "SYSTEM_ADMIN") up = _new(admin, "personal", _combo("개인 것"))["키"] before = len(_list(admin, "system")["줄"]) got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) new = got["새키"][0]["새키"] assert new.startswith("UA") and len(got["줄"]) == before + 1 row = next(r for r in got["줄"] if r["키"] == new) assert row["본뜬키"] is None and row["이름"] == "개인 것" doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) assert m01.cm.check_form(combos.FILE, doc) == []