"""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<>endobj\n" b"2 0 obj<>endobj\n" b"3 0 obj<>>>>>endobj\n" ) stream = ( "BT /F1 12 Tf 50 750 Td (" + safe + ") Tj ET" ).encode("latin-1", errors="replace") pdf += ( b"4 0 obj<>stream\n" + stream + b"\nendstream endobj\n" ) pdf += ( b"5 0 obj<>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<>\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