Files
Aislo/resources/tester/test_538_help_guide.py
T

288 lines
11 KiB
Python

"""PLAN 538 [?] 사용자 가이드 — 변환 글 · 그림 내려주기 · 바로 고침.
짝: `ui_template/ui_template_help.ts`(`renderHelpMarkdown`)
— `helper_538_help_render.cjs` 가 Node 로 돌림 ·
`common_util/common_util_help_router.py`(GET /api/help · 그림 · PUT · POST).
"""
from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_help_router as help_router
from common_util.common_util_auth import verify_session
HERE = os.path.dirname(__file__)
@pytest.fixture(scope="module")
def html() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_538_help_render.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_덩어리마다_원문_줄(html) -> None:
admin = html["admin"]
for tag in [
'<h2 data-src="0-0">',
'<p data-src="2-3">',
'<ul data-src="5-6">',
'class="ui-markdown-table" data-src="8-10"',
'data-src="12-13"',
'<figure data-src="17-17"',
]:
assert tag in admin, tag
assert '<ul data-src="2-3">' in html["crlf"]
def test_페이지_안_넘기면_옛_출력(html) -> None:
"""B01 지식 채팅 — 원문 줄 · 그림 · 상자 · 링크 없음."""
plain = html["plain"]
assert "data-src" not in plain and "<figure" not in plain and "<aside" not in plain
assert plain.startswith("<h2>종단 설계</h2><p>첫 문단 [표준 횡단](B06/00_설명서)")
def test_그림_상자_링크(html) -> None:
admin, user = html["admin"], html["user"]
assert (
'<img src="/api/help/B05/img/b05_full.webp?v=abc" alt="화면 통째" loading="lazy">' in admin
)
assert "<figcaption>화면 통째</figcaption>" in admin
assert "그림 없음 — 더블클릭해 지정" in admin and "그림 없음" not in user
assert "그림 준비 중" in user
assert (
'<aside class="ui-help-note ui-help-note--warn" data-src="12-13"><strong>주의</strong>'
in admin
)
assert '<aside class="ui-help-note ui-help-note--info"' in admin
assert 'data-help-goto="B06/00_설명서">표준 횡단</a>' in admin
assert 'data-help-goto="B05/구조물_돌쌓기(메)">돌쌓기</a>' in admin
def test_설명_속_대괄호도_그림(html) -> None:
out = html["bracket"]
src = "/api/help/B05/img/b05_full.webp?v=abc"
assert '<figure data-src="0-0" class="ui-help-figure" data-img="b05_full.webp">' in out
assert f'<img src="{src}" alt="[저장] 단추 누름" loading="lazy">' in out
assert "<figcaption>[저장] 단추 누름</figcaption>" in out
# 굵게는 캡션에서 풀리고, 링크는 그림 설명 안에서 링크로 풀리지 않음
assert "<figcaption>[저장]·[확정] <strong>굵게</strong> [글](B06/00_설명서)</figcaption>" in out
assert out.count("<figure") == 2
# 링크 문단은 그대로 링크 · 그림 뒤에 글이 붙은 줄은 그림이 아님
assert 'data-help-goto="B06/00_설명서">글</a> 와 [저장] 문단' in out
assert "<p" in out and "덧글" in out
def test_html_은_먼저_막음(html) -> None:
for out in html.values():
assert "<script>" not in out
assert "../x.webp" in html["admin"] and 'data-img="../x.webp"' not in html["admin"]
# ── 서버 ─────────────────────────────────────────────────────────────────────
@pytest.fixture
def root(monkeypatch):
path = Path(tempfile.mkdtemp())
(path / "B05").mkdir()
(path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode())
monkeypatch.setattr(help_router, "HELP_ROOT", path)
yield path
shutil.rmtree(path, ignore_errors=True)
def client(role: str = "SYSTEM_ADMIN") -> TestClient:
app = FastAPI()
app.include_router(help_router.router)
app.dependency_overrides[verify_session] = lambda: {"role": role, "user_id": 1}
return TestClient(app)
def test_guide_admin_칸(root) -> None:
assert client().get("/api/help").json()["admin"] is True
body = client("USER").get("/api/help").json()
assert body["admin"] is False and body["pages"][0]["page"] == "B05"
def test_그림_내려주기_no_cache(root) -> None:
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
res = client("USER").get("/api/help/B05/img/full.webp")
assert res.status_code == 200 and res.content == b"RIFF0000WEBP"
assert res.headers["cache-control"] == "no-cache"
assert res.headers["content-type"] == "image/webp"
assert list(client().get("/api/help").json()["pages"][0]["images"]) == ["full.webp"]
@pytest.mark.parametrize(
"url",
[
"/api/help/B05/img/none.webp",
"/api/help/B05/img/00_설명서.md",
"/api/help/B05/img/..%2F..%2Fmain.py",
"/api/help/B05/img/a.gif",
"/api/help/..%2FB05/img/full.webp",
"/api/help/b05/img/full.webp",
],
)
def test_그림_404(root, url) -> None:
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
assert client().get(url).status_code == 404
# ── 538-3 글 바로 고침 ────────────────────────────────────────────────────────
def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"):
body = {"version": version, "start": start, "end": end, "text": text}
return c.put(f"/api/help/B05/{key}", json=body)
def version_of(root: Path) -> str:
return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes())
def test_그_줄만_갈고_새_판(root) -> None:
old = version_of(root)
res = put(client(), old, 2, 2, "고친 문단\n둘째 줄")
assert res.status_code == 200
item = res.json()["item"]
text = (root / "B05" / "00_설명서.md").read_bytes().decode()
assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"]
assert item["version"] == version_of(root) != old
assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움
assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n"
def test_crlf_bom_그대로(root) -> None:
path = root / "B05" / "00_설명서.md"
path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n")
assert put(client(), version_of(root), 2, 2, "C").status_code == 200
assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n"
def test_판_다르면_409_파일_그대로(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409
assert (root / "B05" / "00_설명서.md").read_bytes() == before
def test_시스템_관리자만_403(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
for role in ["USER", "COMPANY_ADMIN"]:
assert put(client(role), version_of(root), 2, 2, "x").status_code == 403
assert (root / "B05" / "00_설명서.md").read_bytes() == before
@pytest.mark.parametrize(
("start", "end", "key", "code"),
[
(3, 2, "00_설명서", 400),
(0, 9, "00_설명서", 400),
(0, 0, "없음", 404),
(0, 0, "_manual", 404),
(0, 0, "..%2F..%2FCLAUDE", 404),
],
)
def test_나쁜_범위_항목(root, start, end, key, code) -> None:
assert put(client(), version_of(root), start, end, "x", key).status_code == code
def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
def boom(*_args):
raise OSError("쓰기 실패")
monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom)
with pytest.raises(OSError):
put(client(), version_of(root), 2, 2, "반쯤")
assert (root / "B05" / "00_설명서.md").read_bytes() == before
assert sorted(p.name for p in (root / "B05").iterdir()) == [
"00_설명서.md"
] # 임시 파일 남지 않음
# ── 538-4 그림 바꾸기 ─────────────────────────────────────────────────────────
def png(width: int = 40, height: int = 20) -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (width, height), (200, 30, 30)).save(out, "PNG")
return out.getvalue()
def upload(c: TestClient, name: str, blob: bytes, filename: str = "고른.png", page: str = "B05"):
return c.post(
f"/api/help/{page}/img",
data={"name": name},
files={"file": (filename, blob, "application/octet-stream")},
)
def test_그림_webp_로_덮고_manual_에_적음(root) -> None:
from PIL import Image
(root / "B05" / "full.webp").write_bytes(b"old")
res = upload(client(), "full.webp", png(3000, 1500))
assert res.status_code == 200
body = res.json()
with Image.open(root / "B05" / "full.webp") as img:
assert (img.format, img.size) == ("WEBP", (1800, 900)) # 폭 1800 넘으면 줄임
assert body == {
"name": "full.webp",
"version": help_router.image_version(root / "B05" / "full.webp"),
}
assert upload(client(), "new_one.webp", png(), "a.jpg").status_code == 200 # 새 이름도 받음
manual = json.loads((root / "B05" / "_manual.json").read_text(encoding="utf-8"))
assert manual == ["full.webp", "new_one.webp"]
assert [i["key"] for i in client().get("/api/help").json()["pages"][0]["items"]] == [
"00_설명서"
]
@pytest.mark.parametrize(
("name", "blob", "filename", "code"),
[
("full.webp", png(), "고른.gif", 400), # 확장자
("full.webp", b"not an image at all", "고른.png", 400), # 그림 아님
("full.webp", b"", "고른.png", 400),
("../full.webp", png(), "고른.png", 404), # 경로 탈출
("full.gif", png(), "고른.png", 404),
("한글.webp", png(), "고른.png", 404),
],
)
def test_그림_거절(root, name, blob, filename, code) -> None:
assert upload(client(), name, blob, filename).status_code == code
assert sorted(p.name for p in (root / "B05").iterdir()) == ["00_설명서.md"]
def test_그림_5MB_넘으면_거절(root, monkeypatch) -> None:
monkeypatch.setattr(help_router, "MAX_IMG_BYTES", 1000)
assert upload(client(), "full.webp", png(400, 400) + b"\0" * 2000).status_code == 400
def test_그림_시스템_관리자만_403(root) -> None:
assert upload(client("USER"), "full.webp", png()).status_code == 403
assert upload(client(), "full.webp", png(), page="..%2FB05").status_code == 404
assert not (root / "B05" / "full.webp").exists()