Files
srbhr--resume-matcher/apps/backend/tests/unit/test_apply_diffs.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

855 lines
33 KiB
Python

"""Unit tests for apply_diffs() — path resolution, verification gates, and actions."""
import copy
import pytest
from app.schemas.models import ResumeChange
from app.services.improver import apply_diffs
class TestApplyDiffsReplace:
"""Tests for the 'replace' action."""
def test_replace_summary(self, sample_resume):
changes = [
ResumeChange(
path="summary",
action="replace",
original=sample_resume["summary"],
value="Updated summary text.",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(rejected) == 0
assert result["summary"] == "Updated summary text."
def test_replace_description_bullet(self, sample_resume):
original_bullet = sample_resume["workExperience"][0]["description"][1]
changes = [
ResumeChange(
path="workExperience[0].description[1]",
action="replace",
original=original_bullet,
value="Architected microservices migration serving 100K users",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert result["workExperience"][0]["description"][1] == changes[0].value
def test_replace_project_description_bullet(self, sample_resume):
original_bullet = sample_resume["personalProjects"][0]["description"][0]
changes = [
ResumeChange(
path="personalProjects[0].description[0]",
action="replace",
original=original_bullet,
value="Python CLI tool generating API clients from OpenAPI specs",
reason="Added Python keyword",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert result["personalProjects"][0]["description"][0] == changes[0].value
def test_replace_case_insensitive_original_match(self, sample_resume):
original_bullet = sample_resume["workExperience"][0]["description"][0]
changes = [
ResumeChange(
path="workExperience[0].description[0]",
action="replace",
original=original_bullet.upper(), # Case difference
value="New text",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
class TestApplyDiffsAppend:
"""Tests for the 'append' action."""
def test_append_bullet_to_experience(self, sample_resume):
original_count = len(sample_resume["workExperience"][0]["description"])
changes = [
ResumeChange(
path="workExperience[0].description",
action="append",
original=None,
value="Implemented CI/CD pipelines with GitHub Actions",
reason="Added CI/CD keyword",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(result["workExperience"][0]["description"]) == original_count + 1
assert result["workExperience"][0]["description"][-1] == changes[0].value
def test_append_bullet_to_project(self, sample_resume):
original_count = len(sample_resume["personalProjects"][0]["description"])
changes = [
ResumeChange(
path="personalProjects[0].description",
action="append",
original=None,
value="Published to PyPI with 10K+ monthly downloads",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(result["personalProjects"][0]["description"]) == original_count + 1
class TestApplyDiffsAddSkill:
"""Tests for adding verified skills to the technical skills list."""
def test_add_skill_to_technical_skills(self, sample_resume):
changes = [
ResumeChange(
path="additional.technicalSkills",
action="add_skill",
original=None,
value="Kubernetes",
reason="JD-required skill approved by verifier",
)
]
result, applied, rejected = apply_diffs(
sample_resume,
changes,
allowed_skill_targets=[{"skill": "Kubernetes"}],
)
assert len(applied) == 1
assert len(rejected) == 0
assert "Kubernetes" in result["additional"]["technicalSkills"]
def test_add_skill_rejects_unverified_skill(self, sample_resume):
changes = [
ResumeChange(
path="additional.technicalSkills",
action="add_skill",
original=None,
value="BananaDB",
reason="Unsupported skill should not be appended",
)
]
result, applied, rejected = apply_diffs(
sample_resume,
changes,
allowed_skill_targets=[{"skill": "Kubernetes"}],
)
assert len(applied) == 0
assert len(rejected) == 1
assert "BananaDB" not in result["additional"]["technicalSkills"]
def test_add_skill_rejects_duplicate_case_insensitive(self, sample_resume):
changes = [
ResumeChange(
path="additional.technicalSkills",
action="add_skill",
original=None,
value="python",
reason="Duplicate skill should not be appended",
)
]
result, applied, rejected = apply_diffs(
sample_resume,
changes,
allowed_skill_targets=[{"skill": "Python"}],
)
assert len(applied) == 0
assert len(rejected) == 1
assert result["additional"]["technicalSkills"].count("Python") == 1
def test_add_skill_rejects_non_skill_path(self, sample_resume):
changes = [
ResumeChange(
path="summary",
action="add_skill",
original=None,
value="Kubernetes",
reason="Skill additions are only allowed in technical skills",
)
]
result, applied, rejected = apply_diffs(
sample_resume,
changes,
allowed_skill_targets=[{"skill": "Kubernetes"}],
)
assert len(applied) == 0
assert len(rejected) == 1
assert "Kubernetes" not in result["additional"]["technicalSkills"]
class TestApplyDiffsReorder:
"""Tests for the 'reorder' action."""
def test_reorder_skills(self, sample_resume):
original_skills = sample_resume["additional"]["technicalSkills"]
reordered = list(reversed(original_skills))
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=reordered,
reason="Prioritized relevant skills",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert result["additional"]["technicalSkills"] == reordered
def test_reorder_with_unverified_items_drops_them_keeps_originals(self, sample_resume):
"""Issue #736: a reorder mixing in new items is salvaged, not dropped —
new items without a verified target are removed, originals preserved."""
original = sample_resume["additional"]["technicalSkills"]
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Python", "Kubernetes", "Go"], # Kubernetes/Go new, no verified targets
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1 and len(rejected) == 0
assert "Kubernetes" not in skills and "Go" not in skills
assert set(skills) == set(original)
assert skills[0] == "Python"
def test_reorder_case_insensitive_matching(self, sample_resume):
original_skills = sample_resume["additional"]["technicalSkills"]
reordered = [s.lower() for s in reversed(original_skills)]
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=reordered,
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
def test_reorder_accepts_list_original_and_applies(self, sample_resume):
# The live LLM ignores the prompt's `original: null` for reorder and sends
# the current skills LIST as `original`. The schema must accept that — a
# `str | None`-only `original` dropped the whole change at parse time with a
# `string_type` error ("Skipping malformed change") — and a pure reorder
# (same items, new order) must still apply.
original_skills = sample_resume["additional"]["technicalSkills"]
reordered = list(reversed(original_skills))
change = ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=original_skills, # a LIST, exactly as the LLM sends it
value=reordered,
reason="prioritize JD-relevant skills",
)
assert change.original == original_skills
result, applied, rejected = apply_diffs(sample_resume, [change])
assert len(applied) == 1
assert result["additional"]["technicalSkills"] == reordered
def test_list_original_rejected_for_non_reorder_actions(self):
# A list `original` is valid ONLY for reorder. For a text action like
# replace it would bypass the original-match gate and crash the
# invented-metrics check, so the schema rejects it at construction.
from pydantic import ValidationError
with pytest.raises(ValidationError):
ResumeChange(
path="summary",
action="replace",
original=["a", "b"], # list original on a text action — invalid
value="new summary",
reason="test",
)
class TestApplyDiffsBlockedPaths:
"""Tests for blocked path rejection."""
@pytest.mark.parametrize("path,original_val", [
("personalInfo.name", "Jane Doe"),
("personalInfo.email", "jane@example.com"),
])
def test_reject_personal_info(self, sample_resume, path, original_val):
changes = [
ResumeChange(path=path, action="replace", original=original_val, value="X", reason="test")
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
assert len(applied) == 0
def test_reject_date_change(self, sample_resume):
changes = [
ResumeChange(
path="workExperience[0].years",
action="replace",
original="Jan 2021 - Present",
value="Jan 2019 - Present",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_company_change(self, sample_resume):
changes = [
ResumeChange(
path="workExperience[0].company",
action="replace",
original="Acme Corp",
value="Google",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_education_change(self, sample_resume):
changes = [
ResumeChange(
path="education[0].degree",
action="replace",
original="B.S. Computer Science",
value="M.S. Computer Science",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_custom_sections(self, sample_resume):
changes = [
ResumeChange(
path="customSections.volunteer",
action="replace",
original=None,
value="Volunteer work",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_project_role(self, sample_resume):
changes = [
ResumeChange(
path="personalProjects[0].role",
action="replace",
original="Creator & Maintainer",
value="CTO",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_location_change(self, sample_resume):
changes = [
ResumeChange(
path="workExperience[0].location",
action="replace",
original="San Francisco, CA",
value="Remote",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
class TestApplyDiffsVerificationGates:
"""Tests for path resolution and original text verification."""
def test_reject_out_of_bounds_index(self, sample_resume):
changes = [
ResumeChange(
path="workExperience[99].description[0]",
action="replace",
original="Nonexistent",
value="New",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_original_text_mismatch(self, sample_resume):
changes = [
ResumeChange(
path="workExperience[0].description[0]",
action="replace",
original="This text does not exist anywhere in the resume",
value="New text",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reject_unknown_action_at_schema_level(self, sample_resume):
"""Pydantic Literal type prevents invalid actions before apply_diffs sees them."""
from pydantic import ValidationError
with pytest.raises(ValidationError):
ResumeChange(
path="summary",
action="delete", # type: ignore[arg-type]
original=sample_resume["summary"],
value="",
reason="test",
)
def test_reject_nonexistent_path(self, sample_resume):
changes = [
ResumeChange(
path="nonexistent.field",
action="replace",
original="x",
value="y",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
class TestApplyDiffsIntegrity:
"""Tests for data integrity and multi-change scenarios."""
def test_does_not_mutate_original(self, sample_resume):
original_copy = copy.deepcopy(sample_resume)
changes = [
ResumeChange(
path="summary",
action="replace",
original=sample_resume["summary"],
value="Changed",
reason="test",
)
]
result, _, _ = apply_diffs(sample_resume, changes)
assert sample_resume == original_copy
assert result["summary"] == "Changed"
def test_multiple_changes_partial_rejection(self, sample_resume):
changes = [
ResumeChange(
path="summary",
action="replace",
original=sample_resume["summary"],
value="New summary",
reason="good",
),
ResumeChange(
path="personalInfo.name",
action="replace",
original="Jane Doe",
value="Bad",
reason="blocked",
),
ResumeChange(
path="workExperience[0].description[0]",
action="replace",
original=sample_resume["workExperience"][0]["description"][0],
value="Updated bullet",
reason="good",
),
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 2
assert len(rejected) == 1
assert result["summary"] == "New summary"
assert result["personalInfo"]["name"] == "Jane Doe" # Unchanged
def test_empty_changes_list(self, sample_resume):
result, applied, rejected = apply_diffs(sample_resume, [])
assert len(applied) == 0
assert len(rejected) == 0
assert result == sample_resume
def test_all_changes_applied(self, sample_resume, sample_changes):
result, applied, rejected = apply_diffs(sample_resume, sample_changes)
assert len(rejected) == 0
assert len(applied) == len(sample_changes)
class TestApplyDiffsEdgeCases:
"""Edge cases: hostile inputs, malformed paths, boundary conditions."""
def test_append_to_non_list_rejected(self, sample_resume):
"""Append to a string field (summary) should be rejected."""
changes = [
ResumeChange(
path="summary",
action="append",
original=None,
value="Extra text",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
assert len(applied) == 0
def test_reorder_non_list_rejected(self, sample_resume):
"""Reorder on a string field should be rejected."""
changes = [
ResumeChange(
path="summary",
action="reorder",
original=None,
value=["a", "b"],
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reorder_with_non_list_value_rejected(self, sample_resume):
"""Reorder where value is a string instead of list should be rejected."""
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value="Python, Docker", # type: ignore[arg-type]
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_reorder_with_duplicates_is_deduped_and_preserves_originals(self, sample_resume):
"""Issue #736: a reorder with a duplicate and missing items is salvaged —
the duplicate is collapsed and every original is preserved (no loss)."""
original = sample_resume["additional"]["technicalSkills"]
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Python", "Python", "Docker", "AWS", "PostgreSQL"], # Python duplicated, Redis/FastAPI missing
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1
assert skills.count("Python") == 1 # duplicate collapsed
assert set(skills) == set(original) # nothing lost
def test_empty_path_rejected(self, sample_resume):
"""Empty path string should be rejected."""
changes = [
ResumeChange(
path="",
action="replace",
original="x",
value="y",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_deeply_nested_invalid_path_rejected(self, sample_resume):
"""Path with too many segments that don't resolve."""
changes = [
ResumeChange(
path="workExperience[0].description[0].nested.deep",
action="replace",
original="x",
value="y",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_negative_index_rejected(self, sample_resume):
"""Negative array index should not resolve."""
changes = [
ResumeChange(
path="workExperience[-1].description[0]",
action="replace",
original="x",
value="y",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(rejected) == 1
def test_second_experience_entry(self, sample_resume):
"""Changes to the second work experience entry should work."""
original_bullet = sample_resume["workExperience"][1]["description"][0]
changes = [
ResumeChange(
path="workExperience[1].description[0]",
action="replace",
original=original_bullet,
value="Updated payment system description",
reason="test",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert result["workExperience"][1]["description"][0] == "Updated payment system description"
# First entry unchanged
assert result["workExperience"][0]["description"][0] == sample_resume["workExperience"][0]["description"][0]
class TestApplyDiffsNewPaths:
"""Newly allowed paths for issue #805 (broader diff scope, casing preserved)."""
def test_replace_education_description(self, sample_resume):
"""Education description (a single string) should be replaceable."""
changes = [
ResumeChange(
path="education[0].description",
action="replace",
original=sample_resume["education"][0]["description"],
value="Graduated with honors; focus on distributed systems and APIs",
reason="surface relevant coursework",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(rejected) == 0
assert result["education"][0]["description"] == changes[0].value
def test_reject_education_description_list_index(self, sample_resume):
"""Education description is a scalar string, so the [j] bullet form is not allowed."""
changes = [
ResumeChange(
path="education[0].description[0]",
action="replace",
original="Graduated with honors, Dean's List",
value="anything",
reason="test",
)
]
_result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 0
assert len(rejected) == 1
def test_reorder_languages(self, sample_resume):
"""Languages list should be reorderable (same items, new order)."""
original = sample_resume["additional"]["languages"]
changes = [
ResumeChange(
path="additional.languages",
action="reorder",
original=None,
value=list(reversed(original)),
reason="prioritize Spanish for this role",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(rejected) == 0
assert result["additional"]["languages"] == list(reversed(original))
def test_reorder_awards(self, sample_resume):
"""Awards list should be reorderable."""
original = sample_resume["additional"]["awards"]
changes = [
ResumeChange(
path="additional.awards",
action="reorder",
original=None,
value=list(original),
reason="no change needed",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(rejected) == 0
assert result["additional"]["awards"] == original
def test_reorder_certifications(self, sample_resume):
"""Certifications list should be reorderable."""
original = sample_resume["additional"]["certificationsTraining"]
changes = [
ResumeChange(
path="additional.certificationsTraining",
action="reorder",
original=None,
value=list(original),
reason="no change needed",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1
assert len(rejected) == 0
@pytest.mark.parametrize(
"path,original,value",
[
("education[0].degree", "B.S. Computer Science", "M.S. Computer Science"),
("education[0].institution", "MIT", "Stanford"),
("education[0].years", "2014 - 2018", "2014 - 2020"),
],
)
def test_reject_blocked_education_fields(self, sample_resume, path, original, value):
"""Degree/institution/years stay blocked even though description is now allowed."""
changes = [
ResumeChange(
path=path,
action="replace",
original=original,
value=value,
reason="test",
)
]
_result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 0
assert len(rejected) == 1
class TestReorderSalvage:
"""Issue #736: a reorder whose items don't exactly match the original must be
SALVAGED, not dropped wholesale — reorder existing items, never lose an
original, and (skills only) add new items that pass the verified gate."""
def test_reorder_with_verified_new_skill_is_salvaged(self, sample_resume):
original = sample_resume["additional"]["technicalSkills"] # 6 items
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["FastAPI", "Python", "Docker", "AWS", "PostgreSQL", "Redis", "Kubernetes"],
reason="surface JD skills; Kubernetes is JD-required",
)
]
result, applied, rejected = apply_diffs(
sample_resume, changes, allowed_skill_targets=[{"skill": "Kubernetes"}]
)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1 and len(rejected) == 0
assert "Kubernetes" in skills # verified new skill added
assert set(original).issubset(set(skills)) # no original lost
assert skills[0] == "FastAPI" # LLM order honored
def test_reorder_with_unverified_new_skill_drops_only_that_skill(self, sample_resume):
original = sample_resume["additional"]["technicalSkills"]
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Redis", "Python", "FastAPI", "Docker", "AWS", "PostgreSQL", "Rust"],
reason="Rust is not in the verified targets",
)
]
result, applied, rejected = apply_diffs(
sample_resume, changes, allowed_skill_targets=[{"skill": "Kubernetes"}]
)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1 # salvaged, not rejected
assert "Rust" not in skills # unverified addition dropped
assert set(original).issubset(set(skills)) # originals preserved
assert skills[0] == "Redis"
def test_reorder_omitting_original_skill_preserves_it(self, sample_resume):
original = sample_resume["additional"]["technicalSkills"]
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Redis", "Python"], # omits 4 originals
reason="prioritize two skills",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1
assert set(skills) == set(original) # nothing lost
assert skills[:2] == ["Redis", "Python"] # requested order first
def test_reorder_languages_with_new_item_drops_it(self, sample_resume):
original = sample_resume["additional"]["languages"]
changes = [
ResumeChange(
path="additional.languages",
action="reorder",
original=None,
value=["Spanish (Conversational)", "English (Native)", "French (Fluent)"],
reason="no verified-target gate for languages — must not fabricate",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
langs = result["additional"]["languages"]
assert len(applied) == 1
assert "French (Fluent)" not in langs # no fabrication
assert set(langs) == set(original)
def test_pure_permutation_still_applies(self, sample_resume):
"""Regression: an exact permutation keeps working unchanged."""
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Redis", "PostgreSQL", "AWS", "Docker", "FastAPI", "Python"],
reason="pure reorder",
)
]
result, applied, rejected = apply_diffs(sample_resume, changes)
assert len(applied) == 1 and len(rejected) == 0
assert result["additional"]["technicalSkills"] == [
"Redis", "PostgreSQL", "AWS", "Docker", "FastAPI", "Python"
]
def test_salvage_places_verified_new_skill_in_requested_position(self, sample_resume):
"""PR #830 review (Copilot): a verified new skill the model puts near the
top of the reorder must land there, not be appended last."""
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Kubernetes", "Python", "FastAPI", "Docker", "AWS", "PostgreSQL", "Redis"],
reason="prioritize Kubernetes (JD-required, verified)",
)
]
result, applied, rejected = apply_diffs(
sample_resume, changes, allowed_skill_targets=[{"skill": "Kubernetes"}]
)
skills = result["additional"]["technicalSkills"]
assert skills[0] == "Kubernetes" # requested position honored, not appended last
assert set(sample_resume["additional"]["technicalSkills"]).issubset(set(skills))
def test_salvage_preserves_case_duplicate_originals(self, sample_resume):
"""PR #830 review (kilo): case-duplicate originals must not be lost."""
resume = {"additional": {"technicalSkills": ["python", "Python", "Docker"]}}
changes = [
ResumeChange(
path="additional.technicalSkills",
action="reorder",
original=None,
value=["Docker", "python", "Go"], # Go new+unverified, both pythons are originals
reason="test dup handling",
)
]
result, applied, rejected = apply_diffs(resume, changes)
skills = result["additional"]["technicalSkills"]
assert len(applied) == 1
# Both case-variants of the original survive; the unverified new item is dropped.
assert sorted(skills) == sorted(["python", "Python", "Docker"])
assert "Go" not in skills