Spaces:
Runtime error
Runtime error
| """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 | |
| 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 | |