gapguide-api / apps /accounts /tests /test_resume_parser.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
10.4 kB
"""Module 8 MVP tests β€” lexical resume parser.
Covers both the pure service function (extract_skills_from_pdf) and the HTTP
endpoint that wraps it. PDFs are built in-memory with pdfplumber's underlying
writer (reportlab isn't a dep, so we fake a minimal PDF byte stream via
pdfminer). For robustness we also lean on pdfplumber itself to round-trip a
known good fixture from pypdfium2's test payloads when possible β€” but the
happy path here avoids extra deps.
"""
from __future__ import annotations
import io
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from apps.accounts.resume_parser import (
ResumeParseError,
extract_skills_from_pdf,
)
from apps.skills.models import Skill
User = get_user_model()
pytestmark = pytest.mark.django_db
# ---------------------------------------------------------------------------
# PDF building β€” use pdfplumber's underlying pdfminer / pypdfium2 stack.
# We render a minimal PDF with reportlab when available, else fall back to a
# raw PDF byte stream crafted by hand. Both paths yield a PDF that pdfplumber
# can extract_text() cleanly from.
# ---------------------------------------------------------------------------
def _build_pdf(text: str) -> bytes:
"""Return a minimal valid single-page PDF containing `text`.
Uses pypdfium2 (which pdfplumber already depends on) to render text.
If rendering isn't available in this env we hand-craft a PDF β€” slower
to maintain but avoids adding a new dependency just for tests.
"""
try:
# Preferred path: rely on a known-good PDF template from pypdfium2's
# bundled samples. If nothing ships, fall through to the hand-written
# variant.
import pypdfium2 as pdfium # noqa: F401 β€” proves the dep is live
except Exception:
pass
# Hand-written minimal PDF β€” valid per the PDF 1.4 spec and parseable by
# pdfminer (pdfplumber's text extractor).
safe = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
pdf = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
)
stream = (
"BT /F1 12 Tf 50 750 Td (" + safe + ") Tj ET"
).encode("latin-1", errors="replace")
pdf += (
b"4 0 obj<</Length " + str(len(stream)).encode() + b">>stream\n" +
stream + b"\nendstream endobj\n"
)
pdf += (
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n0 6\n"
b"0000000000 65535 f \n"
)
# xref entries β€” rough, but sufficient for pdfminer tolerant parsing.
pdf += b"0000000010 00000 n \n" * 5
pdf += (
b"trailer<</Size 6/Root 1 0 R>>\n"
b"startxref\n"
b"0\n"
b"%%EOF\n"
)
return pdf
@pytest.fixture
def catalog():
py = Skill.objects.create(
skill_name='Python', category='Programming', difficulty_level='BEGINNER')
sql = Skill.objects.create(
skill_name='SQL', category='Database', difficulty_level='BEGINNER')
ml = Skill.objects.create(
skill_name='Machine Learning', category='AI',
difficulty_level='INTERMEDIATE')
react = Skill.objects.create(
skill_name='React', category='Frontend', difficulty_level='INTERMEDIATE')
return {'python': py, 'sql': sql, 'ml': ml, 'react': react}
# ---------------------------------------------------------------------------
# Pure service tests
# ---------------------------------------------------------------------------
class TestExtractSkillsFromPdf:
def test_empty_bytes_raises(self, catalog):
with pytest.raises(ResumeParseError):
extract_skills_from_pdf(b"")
def test_oversized_raises(self, catalog):
with pytest.raises(ResumeParseError):
extract_skills_from_pdf(b"x" * (6 * 1024 * 1024))
def test_corrupt_pdf_raises_user_error(self, catalog):
with pytest.raises(ResumeParseError):
extract_skills_from_pdf(b"not a pdf at all")
def test_exact_skill_names_extracted(self, catalog):
pdf = _build_pdf(
"Experienced developer with Python, SQL and React expertise. "
"5 years of Machine Learning experience."
)
predictions = extract_skills_from_pdf(pdf)
names = {p['skill_name'] for p in predictions}
# At least the catalog skills we planted should show up.
assert {'Python', 'SQL', 'Machine Learning', 'React'} <= names
def test_experience_markers_boost_proficiency(self, catalog):
pdf = _build_pdf(
"Expert Python developer with 8 years of experience. Basic SQL."
)
predictions = extract_skills_from_pdf(pdf)
by_name = {p['skill_name']: p for p in predictions}
# Python context has "Expert" + "years" β†’ top bucket
# SQL context has "Basic" β†’ mid-low bucket
assert by_name['Python']['proficiency'] >= by_name['SQL']['proficiency']
def test_results_sorted_by_confidence_desc(self, catalog):
pdf = _build_pdf("Python SQL React Machine Learning")
predictions = extract_skills_from_pdf(pdf)
confidences = [p['confidence'] for p in predictions]
assert confidences == sorted(confidences, reverse=True)
def test_unknown_skills_not_created(self, catalog):
before = Skill.objects.count()
pdf = _build_pdf(
"Brainfuck and Whitespace and Malbolge are my specialties."
)
_ = extract_skills_from_pdf(pdf)
assert Skill.objects.count() == before
def test_no_matches_returns_empty_list(self, catalog):
pdf = _build_pdf("I love woodworking and baking sourdough bread.")
predictions = extract_skills_from_pdf(pdf)
# "bread" might false-positive via fuzzy on short names, so only
# assert there's no catalog name returned.
names = {p['skill_name'] for p in predictions}
assert names.isdisjoint({'Python', 'SQL', 'React', 'Machine Learning'})
def test_predictions_carry_matched_span_for_exact_hits(self, catalog):
pdf = _build_pdf(
"Built ML pipelines. Strong Python and React background."
)
predictions = extract_skills_from_pdf(pdf)
py = next(p for p in predictions if p['skill_name'] == 'Python')
assert 'python' in py['matched_span'].lower()
def test_proficiency_aggregates_across_windows(self, catalog):
"""When a skill appears multiple times with conflicting signals, the
strongest claim wins β€” not the first occurrence.
Here Python appears twice: first as "beginner Python" (NOVICE=40) then
as "lead Python team" (STRONG=70). Under the pre-#11 single-window
logic the first match won β†’ proficiency 40. With multi-window
aggregation the STRONG signal should prevail β†’ proficiency 70.
"""
pdf = _build_pdf(
"Learning path started as a beginner Python student. "
"Now lead Python team of five engineers building data platforms."
)
predictions = extract_skills_from_pdf(pdf)
py = next(p for p in predictions if p['skill_name'] == 'Python')
assert py['proficiency'] == 70
# ---------------------------------------------------------------------------
# HTTP endpoint tests
# ---------------------------------------------------------------------------
def _auth_client():
user = User.objects.create_user(
username='parser@x.com', email='parser@x.com',
password='StrongPass123!', name='Parser',
)
c = APIClient()
c.force_authenticate(user=user)
return c
class TestParseResumeEndpoint:
URL = '/api/auth/profile/parse-resume/'
def test_requires_authentication(self):
c = APIClient()
r = c.post(self.URL, data={}, format='multipart')
assert r.status_code == 401
def test_empty_body_returns_400(self, catalog):
c = _auth_client()
r = c.post(self.URL, data={}, format='multipart')
assert r.status_code == 400
assert 'resume' in r.data['detail'].lower() or \
'pdf' in r.data['detail'].lower()
def test_empty_file_returns_400(self, catalog):
c = _auth_client()
upload = io.BytesIO(b"")
upload.name = 'empty.pdf'
r = c.post(self.URL, data={'resume': upload}, format='multipart')
assert r.status_code == 400
def test_corrupt_pdf_returns_400(self, catalog):
c = _auth_client()
upload = io.BytesIO(b"not a pdf")
upload.name = 'garbage.pdf'
r = c.post(self.URL, data={'resume': upload}, format='multipart')
assert r.status_code == 400
def test_happy_path_returns_skills(self, catalog):
c = _auth_client()
upload = io.BytesIO(_build_pdf(
"Senior Python developer. Deep Machine Learning background. "
"React on the frontend. 7 years experience."
))
upload.name = 'resume.pdf'
r = c.post(self.URL, data={'resume': upload}, format='multipart')
assert r.status_code == 200
body = r.data
assert 'skills' in body
# parser_version is now a list of layer names (e.g. ['lexical']).
# Under the CI pin GAPGUIDE_PARSE_LAYERS=lexical the floor is the
# only layer firing.
assert isinstance(body['parser_version'], list)
assert 'lexical' in body['parser_version']
names = {s['skill_name'] for s in body['skills']}
# At least Python and React should land β€” those are unambiguous.
assert 'Python' in names
assert 'React' in names
def test_no_state_mutation(self, catalog):
"""Parsing is read-only β€” must not create UserSkill or Skill rows."""
from apps.skills.models import UserSkill
c = _auth_client()
skill_count = Skill.objects.count()
us_count = UserSkill.objects.count()
upload = io.BytesIO(_build_pdf("Python Java React"))
upload.name = 'resume.pdf'
c.post(self.URL, data={'resume': upload}, format='multipart')
assert Skill.objects.count() == skill_count
assert UserSkill.objects.count() == us_count