from __future__ import annotations from datetime import date, datetime, timezone import re import pytest from resume_harness.models import ( CandidateProfile, ContactInfo, DraftClaim, DraftSection, EvidenceCategory, EvidenceItem, EvidenceSource, GenerationConfig, OutputMode, ResumeDraft, ResumeMode, SectionType, ) from resume_harness.renderer import MarkdownRenderer, render_markdown NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc) def _profile() -> CandidateProfile: facts = [ EvidenceItem( evidence_id=evidence_id, category=EvidenceCategory.PROJECT, content=content, source=EvidenceSource.PORTFOLIO, ) for evidence_id, content in ( ("ev-latency", "결제 API 응답 시간을 단축했다."), ("ev-tests", "회귀 테스트를 자동화했다."), ("ev-python", "Python 서비스를 개발했다."), ) ] return CandidateProfile( candidate_id="candidate-1", name="김하네스", name_en="Harness Kim", contact=ContactInfo( email="harness@example.com", phone="010-1234-5678", city="서울", links=["https://example.com/portfolio"], ), facts=facts, updated_at=NOW, ) def _claim( claim_id: str, text: str, evidence_id: str, order: int ) -> DraftClaim: return DraftClaim( claim_id=claim_id, text=text, evidence_ids=[evidence_id], order=order, ) def _draft(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> ResumeDraft: experience = DraftSection( section_id="section-experience", section_type=SectionType.EXPERIENCE, heading="경력", order=0, claims=[ _claim("claim-tests", "회귀 테스트 자동화", "ev-tests", 1), _claim("claim-latency", "결제 API 응답 시간 단축", "ev-latency", 0), ], ) skills = DraftSection( section_id="section-skills", section_type=SectionType.SKILLS, heading="기술", order=1, claims=[_claim("claim-python", "Python 서비스 개발", "ev-python", 0)], ) return ResumeDraft( draft_id="draft-1", candidate_id="candidate-1", title="백엔드 엔지니어", mode=mode, # Input order is intentionally different from canonical ``order``. sections=[skills, experience], generated_at=NOW, ) def _config(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> GenerationConfig: return GenerationConfig(resume_mode=mode, as_of_date=date(2026, 7, 1)) def test_private_markdown_renders_identity_first_and_uses_stable_order() -> None: output = render_markdown(_draft(), _profile(), _config()) assert output.startswith( "# 김하네스\n" "영문명: Harness Kim\n" "지원 분야: 백엔드 엔지니어\n" "이메일: harness@example.com\n" ) assert output.index("## 경력") < output.index("## 기술") assert output.index("결제 API 응답 시간 단축") < output.index( "회귀 테스트 자동화" ) assert re.search(r"^\|", output, re.MULTILINE) is None assert " None: mode = ResumeMode.PUBLIC_BLIND output = render_markdown( _draft(mode=mode), _profile(), _config(mode=mode) ) assert output.startswith("# 백엔드 엔지니어\n") for private_value in ( "김하네스", "Harness Kim", "harness@example.com", "010-1234-5678", "서울", "https://example.com/portfolio", ): assert private_value not in output def test_public_blind_renderer_fails_closed_on_origin_disclosure() -> None: mode = ResumeMode.PUBLIC_BLIND draft = _draft(mode=mode) origin_claim = next( claim for section in draft.sections for claim in section.claims if claim.evidence_ids == ["ev-python"] ) origin_claim.text = "고향은 대전이며 Python 서비스를 개발했다." with pytest.raises(ValueError, match="PRIVACY.BLIND_ORIGIN"): render_markdown(draft, _profile(), _config(mode=mode)) def test_evidence_ids_are_hidden_by_default_and_available_only_for_debug() -> None: draft = _draft() profile = _profile() config = _config() normal = MarkdownRenderer().render(draft, profile, config) debug = MarkdownRenderer(debug_evidence_ids=True).render( draft, profile, config ) alias = render_markdown( draft, profile, config, include_evidence_ids=True ) assert "ev-latency" not in normal assert "[근거 ID: ev-latency]" in debug assert debug == alias def test_embedded_line_breaks_cannot_create_markdown_blocks() -> None: draft = _draft() draft.sections[0].claims[0].text = "Python 개발\r\n## 위조 섹션 | " output = render_markdown(draft, _profile(), _config()) assert "Python 개발 ## 위조 섹션 | <table>" in output assert "\n## 위조 섹션" not in output assert "
" not in output assert "|" not in output def test_claim_text_cannot_inject_links_images_or_inline_code() -> None: draft = _draft() draft.sections[0].claims[0].text = "![추적](https://bad.example) `숨은 코드` **과장**" output = render_markdown(draft, _profile(), _config()) assert "![추적](" not in output assert "`숨은 코드`" not in output assert "**과장**" not in output assert r"!\[추적\](https://bad.example) \`숨은 코드\` \*\*과장\*\*" in output def test_renderer_rejects_non_markdown_output_mode() -> None: config = GenerationConfig( output_mode=OutputMode.JSON, resume_mode=ResumeMode.PRIVATE_MODERN, as_of_date=date(2026, 7, 1), ) with pytest.raises(ValueError, match="output_mode"): render_markdown(_draft(), _profile(), config) def test_markdown_renderer_does_not_silently_drop_required_photo() -> None: from resume_harness.models import SensitiveDataCategory config = GenerationConfig( output_mode=OutputMode.MARKDOWN, resume_mode=ResumeMode.EMPLOYER_FORM, include_photo=True, allowed_sensitive_categories={SensitiveDataCategory.PHOTO}, employer_required_sensitive_categories={SensitiveDataCategory.PHOTO}, as_of_date=date(2026, 7, 1), ) draft = _draft(mode=ResumeMode.EMPLOYER_FORM) with pytest.raises(ValueError, match="cannot embed a photo"): render_markdown(draft, _profile(), config) def test_renderer_checks_evidence_references_before_output() -> None: draft = _draft() draft.sections[0].claims[0].evidence_ids = ["ev-unknown"] with pytest.raises(ValueError, match="unknown evidence"): render_markdown(draft, _profile(), _config()) def test_renderer_blocks_claims_backed_by_confidential_evidence() -> None: profile = _profile() profile = profile.model_copy( update={ "facts": [ fact.model_copy(update={"confidential": fact.evidence_id == "ev-python"}) for fact in profile.facts ] } ) with pytest.raises(ValueError, match="confidential"): render_markdown(_draft(), profile, _config())