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 = " `숨은 코드` **과장**"
output = render_markdown(draft, _profile(), _config())
assert " \`숨은 코드\` \*\*과장\*\*" 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())