# pyright: reportOptionalCall=false, reportOptionalMemberAccess=false
"""
test_tistory_publisher.py

TistoryPublisher 단위 테스트 (아르고스 작성)

- Playwright 기반 티스토리 자동화 퍼블리셔의 mock 단위 테스트
- 모든 외부 의존성(Playwright, 브라우저)은 AsyncMock/MagicMock으로 대체
- 실제 브라우저 실행 없음

TistoryPublisher._ensure_browser() 구현 패턴:
  pw = await async_playwright().start()   ← async_playwright()는 동기 호출, .start()가 코루틴
  self._browser = await pw.chromium.launch(...)
  self._context = await self._browser.new_context(...)
  self._page    = await self._context.new_page()

테스트 항목:
  1. test_init_from_env:               환경변수에서 설정 로드
  2. test_init_from_args:              생성자 인자로 설정
  3. test_load_session_file_not_found: 세션 파일 없을 때 FileNotFoundError
  4. test_load_session_success:        유효한 세션 파일 로드
  5. test_check_session_valid_true:    세션 유효 시 True 반환
  6. test_check_session_valid_expired: 세션 만료(로그인 리다이렉트) 시 False 반환
  7. test_publish_post_private:        비공개 글 발행 성공
  8. test_publish_post_with_tags:      태그 포함 발행
  9. test_publish_post_session_expired: 세션 만료 시 SessionExpiredError
 10. test_publish_post_failure:        발행 실패 시 PublishError
 11. test_edit_post_success:           글 수정 성공
 12. test_get_post_status:             글 상태 확인
 13. test_upload_image_success:        이미지 업로드 성공
 14. test_close:                       리소스 정리 확인
 15. test_visibility_default_private:  visibility 기본값 private 확인
 16. test_pipeline_parse_html_title:   HTML에서 제목 추출
 17. test_pipeline_parse_html_tags:    HTML meta에서 태그 추출
 18. test_pipeline_file_not_found:     HTML 파일 없을 때 에러
 19. test_pipeline_publish_flow:       전체 파이프라인 흐름 (mock)
"""

import inspect
import json
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import pytest_asyncio

# ---------------------------------------------------------------------------
# 경로 설정
# ---------------------------------------------------------------------------

_WORKSPACE = Path(os.environ.get("WORKSPACE_ROOT", "/home/jay/workspace"))
if str(_WORKSPACE) not in sys.path:
    sys.path.insert(0, str(_WORKSPACE))

# ---------------------------------------------------------------------------
# Import — 구현 파일이 없으면 ImportError를 수집하고 모든 테스트를 skip
# ---------------------------------------------------------------------------

_IMPORT_ERROR: ImportError | None = None

try:
    from scripts.blog.tistory_publisher import (
        LoginRequiredError,
        PublishError,
        SessionExpiredError,
        TistoryPublisher,
    )
except ImportError as _e:
    _IMPORT_ERROR = _e
    TistoryPublisher = None  # type: ignore[assignment,misc]
    SessionExpiredError = type("SessionExpiredError", (Exception,), {})  # type: ignore[assignment,misc]
    PublishError = type("PublishError", (Exception,), {})  # type: ignore[assignment,misc]
    LoginRequiredError = type("LoginRequiredError", (Exception,), {})  # type: ignore[assignment,misc]

_SKIP_IF_NO_IMPL = pytest.mark.skipif(
    _IMPORT_ERROR is not None,
    reason=f"TistoryPublisher 미구현: {_IMPORT_ERROR}",
)

# ---------------------------------------------------------------------------
# Playwright mock 팩토리
#
# TistoryPublisher._ensure_browser() 호출 경로:
#   pw_instance = await async_playwright().start()
#   browser     = await pw_instance.chromium.launch(headless=...)
#   context     = await browser.new_context(**kwargs)
#   page        = await context.new_page()
#
# async_playwright()는 동기 함수이므로 MagicMock()을 반환하게 patch 한다.
# 반환된 객체의 .start()가 AsyncMock이어야 한다.
# ---------------------------------------------------------------------------


def _make_mock_page(url: str = "https://testblog.tistory.com/manage/post/42") -> MagicMock:
    """Playwright Page 객체를 흉내내는 MagicMock을 반환한다."""
    page = MagicMock()
    page.goto = AsyncMock()
    page.fill = AsyncMock()
    page.click = AsyncMock()
    page.wait_for_selector = AsyncMock()
    page.wait_for_load_state = AsyncMock()
    page.wait_for_url = AsyncMock()
    page.evaluate = AsyncMock(return_value=None)
    page.screenshot = AsyncMock()
    page.close = AsyncMock()
    page.locator = MagicMock(return_value=MagicMock(inner_text=AsyncMock(return_value="")))
    page.expect_response = MagicMock()

    # url 프로퍼티: 동기적으로 현재 URL을 반환
    type(page).url = property(lambda self, _u=url: _u)

    return page


def _make_mock_context(page: MagicMock | None = None) -> MagicMock:
    """Playwright BrowserContext 객체를 흉내내는 MagicMock을 반환한다."""
    ctx = MagicMock()
    _page = page or _make_mock_page()
    ctx.new_page = AsyncMock(return_value=_page)
    ctx.add_cookies = AsyncMock()
    ctx.cookies = AsyncMock(return_value=[{"name": "sess", "value": "abc"}])
    ctx.storage_state = AsyncMock(return_value={"cookies": [], "origins": []})
    ctx.close = AsyncMock()
    return ctx


def _make_mock_browser(context: MagicMock | None = None) -> MagicMock:
    """Playwright Browser 객체를 흉내내는 MagicMock을 반환한다."""
    browser = MagicMock()
    _ctx = context or _make_mock_context()
    browser.new_context = AsyncMock(return_value=_ctx)
    browser.close = AsyncMock()
    return browser


def _make_pw_instance(browser: MagicMock | None = None) -> MagicMock:
    """async_playwright().start() 반환값인 Playwright 인스턴스를 흉내낸다."""
    pw_inst = MagicMock()
    _browser = browser or _make_mock_browser()
    pw_inst.chromium = MagicMock()
    pw_inst.chromium.launch = AsyncMock(return_value=_browser)
    pw_inst.stop = AsyncMock()
    return pw_inst


def _make_async_playwright_callable(browser: MagicMock | None = None) -> MagicMock:
    """
    scripts.blog.tistory_publisher.async_playwright 를 대체할 callable을 반환한다.

    _ensure_browser()가 사용하는 패턴:
        pw = await async_playwright().start()

    따라서 async_playwright()는 동기적으로 .start() 메서드를 가진 객체를 반환해야 하고,
    .start()는 코루틴(AsyncMock)이어야 한다.
    """
    pw_inst = _make_pw_instance(browser=browser)
    mock_callable = MagicMock(return_value=MagicMock(start=AsyncMock(return_value=pw_inst)))
    return mock_callable


# ---------------------------------------------------------------------------
# session.json 헬퍼
# ---------------------------------------------------------------------------


def _write_session(tmp_path: Path) -> Path:
    session_file = tmp_path / "session.json"
    session_data = {
        "cookies": [{"name": "TSSESSION", "value": "mock-session-value", "domain": ".tistory.com"}],
        "origins": [],
    }
    session_file.write_text(json.dumps(session_data))
    return session_file


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_playwright_callable():
    """async_playwright를 대체하는 callable fixture."""
    return _make_async_playwright_callable()


@pytest_asyncio.fixture
async def publisher(tmp_path, mock_playwright_callable):
    """TistoryPublisher 인스턴스를 mock playwright와 함께 생성한다."""
    if _IMPORT_ERROR is not None:
        pytest.skip(f"TistoryPublisher 미구현: {_IMPORT_ERROR}")

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_playwright_callable):
        pub = TistoryPublisher(
            blog_name="testblog",
            session_path=str(session_file),
            headless=True,
        )
        yield pub


# ---------------------------------------------------------------------------
# 1. 초기화 테스트
# ---------------------------------------------------------------------------


@_SKIP_IF_NO_IMPL
def test_init_from_env(tmp_path, monkeypatch):
    """환경변수에서 blog_name, session_path를 로드할 수 있어야 한다."""
    session_file = _write_session(tmp_path)

    monkeypatch.setenv("TISTORY_BLOG_NAME", "env-blog")
    monkeypatch.setenv("TISTORY_SESSION_PATH", str(session_file))

    pub = TistoryPublisher(
        blog_name=os.environ["TISTORY_BLOG_NAME"],
        session_path=os.environ["TISTORY_SESSION_PATH"],
    )
    assert pub.blog_name == "env-blog"
    assert pub.session_path == str(session_file)


@_SKIP_IF_NO_IMPL
def test_init_from_args(tmp_path):
    """생성자 인자로 blog_name, session_path, headless를 설정할 수 있어야 한다."""
    session_file = _write_session(tmp_path)

    pub = TistoryPublisher(
        blog_name="my-blog",
        session_path=str(session_file),
        headless=False,
    )
    assert pub.blog_name == "my-blog"
    assert pub.session_path == str(session_file)
    assert pub.headless is False


# ---------------------------------------------------------------------------
# 2. 세션 로드 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_load_session_file_not_found(tmp_path):
    """세션 파일이 없으면 _load_session()이 FileNotFoundError를 발생시켜야 한다."""
    missing_path = str(tmp_path / "nonexistent_session.json")

    pub = TistoryPublisher(blog_name="testblog", session_path=missing_path)
    with pytest.raises(FileNotFoundError):
        await pub._load_session()


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_load_session_success(tmp_path):
    """유효한 세션 파일이 있으면 _load_session()이 정상 완료되어야 한다."""
    session_file = _write_session(tmp_path)

    pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
    # 예외 없이 완료되어야 한다
    await pub._load_session()


# ---------------------------------------------------------------------------
# 3. 세션 유효성 검사
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_check_session_valid_true(tmp_path):
    """로그인 상태의 페이지 URL이면 _check_session_valid()가 True를 반환해야 한다."""
    valid_url = "https://testblog.tistory.com/manage/"
    page = _make_mock_page(url=valid_url)
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        result = await pub._check_session_valid()

    assert result is True


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_check_session_valid_expired(tmp_path):
    """로그인 페이지로 리다이렉트 되면 _check_session_valid()가 False를 반환해야 한다."""
    login_url = "https://www.tistory.com/auth/login"
    page = _make_mock_page(url=login_url)
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        result = await pub._check_session_valid()

    assert result is False


# ---------------------------------------------------------------------------
# 4. 글 발행 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_publish_post_private(tmp_path):
    """비공개(private) 글 발행이 성공하면 URL 문자열을 반환해야 한다."""
    published_url = "https://testblog.tistory.com/42"
    page = _make_mock_page(url=published_url)
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        # 세션 유효성 검사는 True로 단락
        pub._check_session_valid = AsyncMock(return_value=True)

        post_url = await pub.publish_post(
            title="테스트 제목",
            content="<p>본문 내용</p>",
            visibility="private",
        )

    assert post_url is not None
    assert isinstance(post_url, str)


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_publish_post_with_tags(tmp_path):
    """태그 목록을 포함하여 글을 발행할 수 있어야 한다."""
    published_url = "https://testblog.tistory.com/99"
    page = _make_mock_page(url=published_url)
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)
    tags = ["python", "playwright", "tistory"]

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=True)

        post_url = await pub.publish_post(
            title="태그 테스트",
            content="<p>태그 포함 본문</p>",
            tags=tags,
            visibility="private",
        )

    assert post_url is not None
    assert isinstance(post_url, str)


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_publish_post_session_expired(tmp_path):
    """세션이 만료된 상태에서 발행을 시도하면 SessionExpiredError가 발생해야 한다."""
    session_file = _write_session(tmp_path)
    mock_callable = _make_async_playwright_callable()

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=False)

        with pytest.raises(SessionExpiredError):
            await pub.publish_post(title="테스트", content="<p>본문</p>")


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_publish_post_failure(tmp_path):
    """발행 중 예기치 않은 오류가 발생하면 PublishError가 발생해야 한다."""
    # page.wait_for_selector()가 예외를 던지도록 설정
    page = _make_mock_page()
    page.wait_for_selector = AsyncMock(side_effect=Exception("Selector timeout"))
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=True)

        with pytest.raises(PublishError):
            await pub.publish_post(title="실패 테스트", content="<p>본문</p>")


# ---------------------------------------------------------------------------
# 5. 글 수정 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_edit_post_success(tmp_path):
    """post_id와 수정할 필드를 전달하면 edit_post()가 정상 완료되어야 한다."""
    page = _make_mock_page()
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=True)

        # 예외 없이 완료되어야 한다
        await pub.edit_post(
            post_id="42",
            title="수정된 제목",
            content="<p>수정된 본문</p>",
        )


# ---------------------------------------------------------------------------
# 6. 글 상태 확인 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_get_post_status(tmp_path):
    """get_post_status()가 post_id와 상태 정보를 포함한 dict를 반환해야 한다.

    구현 패턴:
        row = page.locator(row_selector)          ← MagicMock
        title = await row.locator(child).inner_text(timeout=...)   ← chained AsyncMock
        status_text = await row.locator(child).inner_text(timeout=...)
    """
    page = _make_mock_page()

    # row.locator(child_selector).inner_text()를 AsyncMock으로 만들어야 한다.
    # row.locator() 반환값의 inner_text 가 AsyncMock이어야 한다.
    mock_child_title = MagicMock()
    mock_child_title.inner_text = AsyncMock(return_value="테스트 제목")

    mock_child_status = MagicMock()
    mock_child_status.inner_text = AsyncMock(return_value="비공개")

    # page.locator() → mock_row
    # mock_row.locator(selector) → 호출 순서에 따라 title/status child 반환
    _child_call_count = 0

    def _child_locator(selector):
        nonlocal _child_call_count
        _child_call_count += 1
        if _child_call_count == 1:
            return mock_child_title
        return mock_child_status

    mock_row = MagicMock()
    mock_row.locator = MagicMock(side_effect=_child_locator)

    page.locator = MagicMock(return_value=mock_row)

    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=True)

        status = await pub.get_post_status(post_id="42")

    assert isinstance(status, dict)
    assert "post_id" in status
    assert status["post_id"] == "42"
    assert "status" in status
    # 비공개 텍스트 → "private"
    assert status["status"] == "private"


# ---------------------------------------------------------------------------
# 7. 이미지 업로드 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_upload_image_success(tmp_path):
    """이미지 파일 경로를 전달하면 upload_image()가 업로드된 URL을 반환해야 한다.

    구현 패턴:
        async with page.expect_response(...) as response_info:
            await file_input.set_input_files(image_path)
        response = await response_info.value          ← value가 직접 awaitable
        response_json = await response.json()

    `await response_info.value` 는 `.value` 속성이 코루틴/Future여야 한다.
    asyncio.Future를 사용하여 mock_response를 즉시 반환하도록 설정한다.
    """
    import asyncio

    expected_url = "https://img1.daumcdn.net/thumb/test_image.png"

    # 실제 이미지 파일 생성 (더미 PNG 바이너리)
    image_file = tmp_path / "test_image.png"
    image_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

    session_file = _write_session(tmp_path)

    # response.json()은 코루틴
    mock_response = MagicMock()
    mock_response.json = AsyncMock(return_value={"url": expected_url})

    # response_info.value 는 `await response_info.value` 로 사용됨
    # asyncio.Future를 사용하면 `await future` 가 직접 동작한다
    loop = asyncio.get_event_loop()
    response_future: asyncio.Future = loop.create_future()
    response_future.set_result(mock_response)

    mock_response_info = MagicMock()
    mock_response_info.value = response_future

    # page.expect_response() → async context manager → response_info 반환
    mock_cm = MagicMock()
    mock_cm.__aenter__ = AsyncMock(return_value=mock_response_info)
    mock_cm.__aexit__ = AsyncMock(return_value=False)

    page = _make_mock_page()
    page.expect_response = MagicMock(return_value=mock_cm)

    # file_input 로케이터
    mock_file_input = MagicMock()
    mock_file_input.set_input_files = AsyncMock()
    page.locator = MagicMock(return_value=mock_file_input)

    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))
        pub._check_session_valid = AsyncMock(return_value=True)

        url = await pub.upload_image(image_path=str(image_file))

    assert isinstance(url, str)
    assert url.startswith("https://")


# ---------------------------------------------------------------------------
# 8. 리소스 정리 테스트
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@_SKIP_IF_NO_IMPL
async def test_close(tmp_path):
    """close()를 호출하면 page/context/browser 리소스가 정리되어야 한다."""
    page = _make_mock_page()
    ctx = _make_mock_context(page=page)
    browser = _make_mock_browser(context=ctx)
    mock_callable = _make_async_playwright_callable(browser=browser)

    session_file = _write_session(tmp_path)

    with patch("scripts.blog.tistory_publisher.async_playwright", mock_callable):
        pub = TistoryPublisher(blog_name="testblog", session_path=str(session_file))

        # 인스턴스 변수에 직접 mock 주입 (close()가 None 체크 후 호출하는 패턴)
        pub._page = page
        pub._context = ctx
        pub._browser = browser

        await pub.close()

    # 각 리소스 close()가 호출되었는지 확인
    page.close.assert_awaited_once()
    ctx.close.assert_awaited_once()
    browser.close.assert_awaited_once()

    # 내부 참조가 None으로 초기화되어야 한다
    assert pub._page is None
    assert pub._context is None
    assert pub._browser is None


# ---------------------------------------------------------------------------
# 9. 기본값 테스트
# ---------------------------------------------------------------------------


@_SKIP_IF_NO_IMPL
def test_visibility_default_private():
    """publish_post()의 visibility 기본값은 'private'이어야 한다."""
    sig = inspect.signature(TistoryPublisher.publish_post)
    params = sig.parameters
    assert "visibility" in params, "publish_post에 visibility 파라미터가 없음"
    assert (
        params["visibility"].default == "private"
    ), f"visibility 기본값이 'private'이 아님: {params['visibility'].default!r}"


# ---------------------------------------------------------------------------
# 10. 파이프라인 테스트 (publish_pipeline.py 관련)
# ---------------------------------------------------------------------------

_PIPELINE_IMPORT_ERROR: ImportError | None = None
_parse_html_file = None
_run_pipeline = None

try:
    from scripts.blog.publish_pipeline import parse_html_file, run_pipeline

    _parse_html_file = parse_html_file
    _run_pipeline = run_pipeline
except ImportError as _pe:
    _PIPELINE_IMPORT_ERROR = _pe

_SKIP_IF_NO_PIPELINE = pytest.mark.skipif(
    _PIPELINE_IMPORT_ERROR is not None,
    reason=f"publish_pipeline 미구현: {_PIPELINE_IMPORT_ERROR}",
)


@_SKIP_IF_NO_PIPELINE
def test_pipeline_parse_html_title(tmp_path):
    """HTML 파일에서 <title> 또는 <h1> 태그의 제목을 추출해야 한다."""
    html_file = tmp_path / "post.html"
    html_file.write_text(
        "<!DOCTYPE html>\n"
        "<html>\n"
        "<head><title>파이썬으로 배우는 Playwright</title></head>\n"
        "<body><h1>파이썬으로 배우는 Playwright</h1></body>\n"
        "</html>",
        encoding="utf-8",
    )

    result = _parse_html_file(html_file)
    assert "Playwright" in result["title"] or "파이썬" in result["title"]


@_SKIP_IF_NO_PIPELINE
def test_pipeline_parse_html_tags(tmp_path):
    """HTML meta keywords에서 태그 목록을 추출해야 한다."""
    html_file = tmp_path / "post.html"
    html_file.write_text(
        "<!DOCTYPE html>\n"
        "<html>\n"
        "<head>\n"
        "  <title>테스트</title>\n"
        '  <meta name="keywords" content="python, playwright, automation">\n'
        "</head>\n"
        "<body><p>본문</p></body>\n"
        "</html>",
        encoding="utf-8",
    )

    result = _parse_html_file(html_file)
    keywords = result["keywords"]
    assert isinstance(keywords, str)
    assert "python" in keywords.lower() or "playwright" in keywords.lower()


@_SKIP_IF_NO_PIPELINE
def test_pipeline_file_not_found():
    """존재하지 않는 HTML 파일을 파싱하면 FileNotFoundError가 발생해야 한다."""
    missing = Path("/tmp/nonexistent_tistory_post_12345.html")
    with pytest.raises(FileNotFoundError):
        _parse_html_file(missing)


@pytest.mark.asyncio
@_SKIP_IF_NO_PIPELINE
@_SKIP_IF_NO_IMPL
async def test_pipeline_publish_flow(tmp_path):
    """전체 파이프라인 흐름: parse_html_file → run_pipeline → publish_post 호출 확인."""
    html_file = tmp_path / "post.html"
    html_file.write_text(
        "<!DOCTYPE html>\n"
        "<html>\n"
        "<head>\n"
        "  <title>파이프라인 통합 테스트</title>\n"
        '  <meta name="keywords" content="test, pipeline">\n'
        "</head>\n"
        "<body><p>파이프라인 본문입니다.</p></body>\n"
        "</html>",
        encoding="utf-8",
    )

    # parse_html_file이 올바르게 파싱하는지 확인
    result = _parse_html_file(html_file)
    assert result["title"] == "파이프라인 통합 테스트"
    assert "test" in result["keywords"]
    assert result["body"] is not None and len(result["body"]) > 0
