gapguide-api / tests /test_e2e_student_flow.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
23 kB
"""End-to-end cross-module smoke tests.
Exercises the full Student happy path through the HTTP layer only:
register -> skills catalog -> set user skills -> roles catalog -> set target
role -> gap analysis -> resources browse -> checkpoint toggle -> progress rollup.
If any seam between apps breaks (model FK drift, URL routing, serializer
shape), these fail. Unit tests in each app won't catch those regressions.
"""
from pathlib import Path
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework.test import APIClient
from apps.resources.models import Resource, ResourceCheckpoint, SkillResource
from apps.roles.models import Role, RoleSkill
from apps.skills.models import Skill
pytestmark = pytest.mark.django_db
REGISTER = '/api/auth/register/'
SKILLS = '/api/skills/'
USER_SKILLS = '/api/user-skills/'
ROLES = '/api/roles/'
TARGET_ROLE = '/api/target-role/'
GAP_ANALYSIS = '/api/analysis/'
RECOMMENDATIONS = '/api/recommendations/'
RESOURCES = '/api/resources/'
PROGRESS_LIST = '/api/progress/'
PARSE_RESUME = '/api/auth/profile/parse-resume/'
UPGRADE_SUGGESTIONS = '/api/progress/upgrade-suggestions/'
FIXTURES_DIR = Path(__file__).resolve().parent / 'fixtures' / 'resumes'
def _register(client: APIClient, email: str, name: str = 'Test') -> str:
r = client.post(REGISTER, data={
'name': name, 'email': email,
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r.status_code == 201, r.data
return r.data['access']
def auth_client(access_token):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
return c
@pytest.fixture
def catalog():
"""Minimal catalog wired across roles/skills/resources."""
python = 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',
)
role = Role.objects.create(
role_name='Data Scientist', description='DS role',
industry='Tech', is_active=True,
)
RoleSkill.objects.create(
role=role, skill=python, required_level='ADVANCED',
weight=1.0, is_mandatory=True,
)
RoleSkill.objects.create(
role=role, skill=sql, required_level='INTERMEDIATE',
weight=0.8, is_mandatory=True,
)
RoleSkill.objects.create(
role=role, skill=ml, required_level='INTERMEDIATE',
weight=0.6, is_mandatory=False,
)
ml_course = Resource.objects.create(
title='Intro to ML', provider='Coursera',
url='https://coursera.org/ml',
difficulty_level='INTERMEDIATE', duration=3600, type='COURSE',
rating=4.8,
)
SkillResource.objects.create(skill=ml, resource=ml_course, relevance_score=0.95)
for i, t in enumerate(['Week 1', 'Week 2', 'Week 3', 'Week 4'], start=1):
ResourceCheckpoint.objects.create(
resource=ml_course, order_index=i, title=t, source='manual',
)
sql_docs = Resource.objects.create(
title='SQL Docs', provider='PostgreSQL.org',
url='https://postgresql.org/docs/',
difficulty_level='INTERMEDIATE', duration=0, type='DOCS',
rating=4.5,
)
SkillResource.objects.create(skill=sql, resource=sql_docs, relevance_score=0.9)
return {
'skills': {'python': python, 'sql': sql, 'ml': ml},
'role': role,
'resources': {'ml_course': ml_course, 'sql_docs': sql_docs},
}
def test_student_flow_end_to_end(catalog):
"""Full happy-path flow β€” if ANY cross-module seam is broken, this fails."""
# 1. Register ---------------------------------------------------------
client = APIClient()
r = client.post(REGISTER, data={
'name': 'Student A', 'email': 's@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r.status_code == 201, r.data
access = r.data['access']
c = auth_client(access)
# 2. Browse skills (paginated) ----------------------------------------
r = c.get(SKILLS)
assert r.status_code == 200
names = {s['skill_name'] for s in r.data['results']}
assert {'Python', 'SQL', 'Machine Learning'} <= names
# 3. Set user skills (partial coverage β€” ML is not set) ---------------
# Numbers chosen so raw readiness > 60 AND mandatory (Python) is short:
# weighted_sat = 0.7*1.0 + 1.0*0.8 + 0*0.6 = 1.5; / 2.4 * 100 = 62.5
# => mandatory cap pins readiness to 60.0 (PARTIAL band).
for skill_id, prof in [
(catalog['skills']['python'].id, 70), # below ADVANCED (T=100), mandatory
(catalog['skills']['sql'].id, 90), # above INTERMEDIATE (T=60) => MET
]:
r = c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof},
format='json')
assert r.status_code == 201, r.data
r = c.get(USER_SKILLS)
assert r.status_code == 200
assert len(r.data) == 2
# 4. Browse roles -----------------------------------------------------
r = c.get(ROLES)
assert r.status_code == 200
assert any(role['role_name'] == 'Data Scientist' for role in r.data)
# 5. Set target role --------------------------------------------------
r = c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
assert r.status_code == 201, r.data
# 6. Gap analysis β€” verifies skills + roles + analysis seam ----------
r = c.get(GAP_ANALYSIS)
assert r.status_code == 200, r.data
report = r.data
assert report['role_name'] == 'Data Scientist'
assert report['no_requirements'] is False
# Mandatory Python is unmet (70 < 100) -> raw 62.5 capped to 60.
assert report['mandatory_cap_applied'] is True
assert report['readiness'] == 60.0
assert report['band'] == 'PARTIAL'
gaps_by_name = {g['skill_name']: g for g in report['gaps']}
assert gaps_by_name['Python']['gap_type'] == 'INSUFFICIENT'
assert gaps_by_name['Python']['severity'] == 'HIGH' # mandatory shortfall
assert gaps_by_name['SQL']['gap_type'] == 'MET'
assert gaps_by_name['Machine Learning']['gap_type'] == 'MISSING'
assert gaps_by_name['Machine Learning']['severity'] == 'MEDIUM' # not mandatory
# 7. Browse resources filtered by the ML gap (paginated) -------------
ml_id = catalog['skills']['ml'].id
r = c.get(f'{RESOURCES}?skill={ml_id}')
assert r.status_code == 200
assert r.data['count'] == 1
results = r.data['results']
assert results[0]['title'] == 'Intro to ML'
assert results[0]['has_checkpoints'] is True
ml_course_id = results[0]['id']
r = c.get(f'{RESOURCES}{ml_course_id}/')
assert r.status_code == 200
checkpoints = r.data['checkpoints']
assert len(checkpoints) == 4
assert [cp['order_index'] for cp in checkpoints] == [1, 2, 3, 4]
# 8. Toggle checkpoints β€” verifies resources + progress seam ---------
cp1, cp2 = checkpoints[0]['id'], checkpoints[1]['id']
r = c.post(f'/api/progress/checkpoint/{cp1}/toggle/')
assert r.status_code == 200
assert r.data['progress'] == 25
assert r.data['status'] == 'IN_PROGRESS'
r = c.post(f'/api/progress/checkpoint/{cp2}/toggle/')
assert r.data['progress'] == 50
# 9. Manual slider rejected because checkpoints exist ----------------
r = c.post(f'/api/progress/resource/{ml_course_id}/',
data={'progress': 90}, format='json')
assert r.status_code == 400
# 10. Manual slider allowed for docs resource (no checkpoints) -------
docs_id = catalog['resources']['sql_docs'].id
r = c.post(f'/api/progress/resource/{docs_id}/',
data={'progress': 100}, format='json')
assert r.status_code == 200
assert r.data['status'] == 'COMPLETED'
assert r.data['completed_at'] is not None
# 11. Progress list shows BOTH resources, scoped to this user --------
r = c.get(PROGRESS_LIST)
assert r.status_code == 200
assert len(r.data) == 2
by_resource = {row['resource']: row for row in r.data}
assert by_resource[ml_course_id]['progress'] == 50
assert by_resource[ml_course_id]['has_checkpoints'] is True
assert by_resource[docs_id]['progress'] == 100
assert by_resource[docs_id]['has_checkpoints'] is False
def test_gap_analysis_requires_target_role(catalog):
client = APIClient()
r = client.post(REGISTER, data={
'name': 'Student B', 'email': 'b@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
c = auth_client(r.data['access'])
r = c.get(GAP_ANALYSIS)
assert r.status_code == 400
assert 'target role' in r.data['detail'].lower()
def test_gap_analysis_role_id_override_bypasses_target(catalog):
client = APIClient()
r = client.post(REGISTER, data={
'name': 'Student C', 'email': 'c@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
c = auth_client(r.data['access'])
# No target role set β€” but ?role_id=X should still work for "what-if".
r = c.get(f"{GAP_ANALYSIS}?role_id={catalog['role'].id}")
assert r.status_code == 200
assert r.data['role_name'] == 'Data Scientist'
def test_recommendations_flow(catalog):
"""Full recommendations happy path β€” every gap skill gets β‰₯1 rec, the top
rec resolves via /api/resources/<id>/, and the top ML pick matches the
single ML row filtered via ?skill=."""
client = APIClient()
r = client.post(REGISTER, data={
'name': 'Rec Student', 'email': 'rec@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
c = auth_client(r.data['access'])
# Python=70 (mandatory, below ADVANCED=100) β†’ INSUFFICIENT
# SQL=90 (above INTERMEDIATE=60) β†’ MET (excluded from recs)
# ML=0 β†’ MISSING
for skill_id, prof in [
(catalog['skills']['python'].id, 70),
(catalog['skills']['sql'].id, 90),
]:
c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof},
format='json')
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
gap = c.get(GAP_ANALYSIS).data
rec = c.get(RECOMMENDATIONS).data
assert rec['role_id'] == catalog['role'].id
gap_skill_ids = {g['skill_id'] for g in gap['gaps'] if g['gap_type'] != 'MET'}
rec_skill_ids = {int(k) for k in rec['recommendations']}
assert gap_skill_ids == rec_skill_ids
# SQL is MET β†’ must not appear in recommendations.
assert catalog['skills']['sql'].id not in rec_skill_ids
ml_id = catalog['skills']['ml'].id
ml_recs = rec['recommendations'].get(str(ml_id)) \
or rec['recommendations'].get(ml_id)
assert ml_recs, 'ML gap should surface β‰₯1 recommendation'
top_ml = ml_recs[0]
assert top_ml['resource_id'] == catalog['resources']['ml_course'].id
# Top rec must be fetchable via /api/resources/<id>/
r = c.get(f'{RESOURCES}{top_ml["resource_id"]}/')
assert r.status_code == 200
assert r.data['url'] == top_ml['url']
# And cross-check: filtering resources by the ML skill returns the same row.
r = c.get(f'{RESOURCES}?skill={ml_id}')
assert r.status_code == 200
assert r.data['results'][0]['id'] == top_ml['resource_id']
def test_full_closed_loop(catalog):
"""Full gap β†’ recommend β†’ complete β†’ upgrade β†’ re-analyze cycle.
Start: Python=70 (mandatory short), SQL=90 (MET), ML=0.
β†’ gap shows readiness capped at 60 (raw 62.5).
β†’ recommendations suggest the ML course.
β†’ complete ML course via checkpoints β†’ COMPLETED.
β†’ upgrade-suggestions lists ML@60.
β†’ apply β†’ user_skill.ML = 60, user_level INTERMEDIATE.
β†’ re-analyze β†’ ML MET, readiness still capped by Python shortfall.
β†’ complete the SQL DOCS resource (slider path) β†’ apply its upgrade β†’ (no-op
since SQL already at 90, bump map goes to 100 which is new).
"""
client = APIClient()
r = client.post(REGISTER, data={
'name': 'Closed Loop Student', 'email': 'closed@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
c = auth_client(r.data['access'])
for skill_id, prof in [
(catalog['skills']['python'].id, 70),
(catalog['skills']['sql'].id, 90),
]:
c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof},
format='json')
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
pre = c.get(GAP_ANALYSIS).data
assert pre['readiness'] == 60.0
assert pre['mandatory_cap_applied'] is True
rec = c.get(RECOMMENDATIONS).data
ml_id = catalog['skills']['ml'].id
ml_recs = rec['recommendations'].get(str(ml_id)) \
or rec['recommendations'].get(ml_id)
top = ml_recs[0]
assert top['resource_id'] == catalog['resources']['ml_course'].id
# Complete ML via checkpoints.
detail = c.get(f'{RESOURCES}{top["resource_id"]}/').data
for cp in detail['checkpoints']:
c.post(f'/api/progress/checkpoint/{cp["id"]}/toggle/')
suggestions = c.get('/api/progress/upgrade-suggestions/').data['suggestions']
ml_sug = next(s for s in suggestions if s['skill_id'] == ml_id)
assert ml_sug['suggested_proficiency'] == 60
r = c.post(f'/api/progress/upgrade-suggestions/{ml_id}/apply/')
assert r.status_code == 200
assert r.data['applied'] is True
mid = c.get(GAP_ANALYSIS).data
ml_gap = next(g for g in mid['gaps'] if g['skill_id'] == ml_id)
assert ml_gap['gap_type'] == 'MET'
# Python still mandatory short β†’ cap still applied.
assert mid['mandatory_cap_applied'] is True
# Now drive SQL upgrade too via the DOCS slider path (complete β†’ bump).
sql_doc = catalog['resources']['sql_docs']
r = c.post(f'/api/progress/resource/{sql_doc.id}/',
data={'progress': 100}, format='json')
assert r.data['status'] == 'COMPLETED'
sql_id = catalog['skills']['sql'].id
r = c.post(f'/api/progress/upgrade-suggestions/{sql_id}/apply/')
# Bump map: user at 90 β†’ target 100. So this applies.
assert r.status_code == 200
assert r.data['applied'] is True
def test_progress_isolated_per_user(catalog):
"""User A's checkpoint toggles must not leak into User B's progress."""
a_client = APIClient()
b_client = APIClient()
ra = a_client.post(REGISTER, data={
'name': 'A', 'email': 'isoa@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
rb = b_client.post(REGISTER, data={
'name': 'B', 'email': 'isob@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
a = auth_client(ra.data['access'])
b = auth_client(rb.data['access'])
# Both users must select the target role so progress toggles are in-plan.
a.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
b.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
ml_course = catalog['resources']['ml_course']
cp = ml_course.checkpoints.first()
a.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
ra_list = a.get(PROGRESS_LIST).data
rb_list = b.get(PROGRESS_LIST).data
assert len(ra_list) == 1 and ra_list[0]['progress'] == 25
assert len(rb_list) == 0
# -------------------- 7b: added e2e scenarios --------------------
def test_register_duplicate_email_rejected():
"""Second register with the same email (case-insensitive) must 400."""
client = APIClient()
r1 = client.post(REGISTER, data={
'name': 'First', 'email': 'dup@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r1.status_code == 201
# Same email, different casing β€” must still collide.
r2 = client.post(REGISTER, data={
'name': 'Second', 'email': 'DUP@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r2.status_code == 400
# Serializer puts the clash on the email field.
body = r2.data
error_text = str(body).lower()
assert 'email' in error_text and 'already' in error_text
def test_dismiss_then_next_suggestion_excludes(catalog):
"""After dismiss, the same (user, skill) suggestion must not reappear until
a newer resource completion arrives."""
client = APIClient()
access = _register(client, 'dismiss@x.com')
c = auth_client(access)
for skill_id, prof in [
(catalog['skills']['python'].id, 70),
(catalog['skills']['sql'].id, 90),
]:
c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof},
format='json')
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
# Complete the ML course β†’ upgrade suggestion for ML appears.
ml_course = catalog['resources']['ml_course']
for cp in ml_course.checkpoints.all():
c.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
ml_id = catalog['skills']['ml'].id
initial = c.get(UPGRADE_SUGGESTIONS).data['suggestions']
assert any(s['skill_id'] == ml_id for s in initial)
# Dismiss.
r = c.post(f'{UPGRADE_SUGGESTIONS}{ml_id}/dismiss/')
assert r.status_code == 200
assert r.data['dismissed'] is True
# Fetch again β€” ML suggestion must be gone.
after = c.get(UPGRADE_SUGGESTIONS).data['suggestions']
assert not any(s['skill_id'] == ml_id for s in after)
def test_resume_parse_accept_changes_readiness(catalog):
"""Upload a fixture resume β†’ accept predictions β†’ readiness must move.
Bridges Module 8: the parse-resume endpoint returns skill predictions
that, when accepted and upserted into UserSkill, move the gap report.
"""
client = APIClient()
access = _register(client, 'resume@x.com')
c = auth_client(access)
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
# Before: no UserSkill rows β†’ readiness is low.
before = c.get(GAP_ANALYSIS).data
assert before['readiness'] < 40
# Upload the DS-strong fixture. The lexical-layer MVP extracts Python + SQL
# (both in the catalog fixture) β€” assert they appear in predictions.
pdf_path = FIXTURES_DIR / 'resume_ds_strong.pdf'
assert pdf_path.exists(), 'fixture missing β€” run scripts/generate_resume_fixtures.py'
upload = SimpleUploadedFile(
pdf_path.name, pdf_path.read_bytes(), content_type='application/pdf',
)
r = c.post(PARSE_RESUME, data={'resume': upload}, format='multipart')
assert r.status_code == 200, r.data
predicted_names = {p['skill_name'] for p in r.data['skills']}
assert {'Python', 'SQL'}.issubset(predicted_names), predicted_names
# Accept the Python + SQL predictions.
predictions_by_name = {p['skill_name']: p for p in r.data['skills']}
for name in ('Python', 'SQL'):
p = predictions_by_name[name]
r = c.post(USER_SKILLS, data={
'skill_id': p['skill_id'],
'proficiency': p['proficiency'],
}, format='json')
assert r.status_code == 201, r.data
after = c.get(GAP_ANALYSIS).data
assert after['readiness'] > before['readiness']
def test_target_role_switch_recomputes_gap(catalog):
"""Switching target role must invalidate stale gap output."""
# Add a second role inline.
other_role = Role.objects.create(
role_name='Frontend Developer', description='FE',
industry='Tech', is_active=True,
)
# No RoleSkills on the second role β€” gap becomes a "no requirements" report.
client = APIClient()
access = _register(client, 'switch@x.com')
c = auth_client(access)
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
first = c.get(GAP_ANALYSIS).data
assert first['role_name'] == 'Data Scientist'
c.post(TARGET_ROLE, data={'role_id': other_role.id}, format='json')
second = c.get(GAP_ANALYSIS).data
assert second['role_name'] == 'Frontend Developer'
assert second['no_requirements'] is True
assert second['readiness'] == 100.0
def test_checkpoint_untoggle_decreases_progress(catalog):
"""Toggle endpoint is bidirectional β€” untoggle must reduce progress."""
client = APIClient()
access = _register(client, 'untoggle@x.com')
c = auth_client(access)
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
ml_course = catalog['resources']['ml_course']
cps = list(ml_course.checkpoints.order_by('order_index'))
r = c.post(f'/api/progress/checkpoint/{cps[0].id}/toggle/')
assert r.data['progress'] == 25
r = c.post(f'/api/progress/checkpoint/{cps[1].id}/toggle/')
assert r.data['progress'] == 50
# Untoggle the first checkpoint β€” progress must drop back to 25.
r = c.post(f'/api/progress/checkpoint/{cps[0].id}/toggle/')
assert r.data['progress'] == 25
assert r.data['status'] == 'IN_PROGRESS'
def test_manual_slider_rejection_includes_checkpoint_word(catalog):
"""The FE at Learning.jsx:55 substring-matches 'checkpoint' in the error
detail to decide whether to hide the slider. Contract lives here."""
client = APIClient()
access = _register(client, 'slider@x.com')
c = auth_client(access)
c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json')
ml_course = catalog['resources']['ml_course'] # has checkpoints
r = c.post(f'/api/progress/resource/{ml_course.id}/',
data={'progress': 50}, format='json')
assert r.status_code == 400
detail = str(r.data).lower()
assert 'checkpoint' in detail, r.data
def test_resources_pagination_contract(catalog):
"""/api/resources/?skill=X must return {count, results} regardless of
row count. Contract the FE pagination code relies on."""
# Add 12 extra resources linked to ML so count > default page sample.
ml = catalog['skills']['ml']
for i in range(12):
r = Resource.objects.create(
title=f'ML extra {i}', provider='Test',
url=f'https://example.com/ml/{i}',
difficulty_level='INTERMEDIATE', duration=60, type='ARTICLE',
rating=3.5,
)
SkillResource.objects.create(skill=ml, resource=r, relevance_score=0.5)
client = APIClient()
access = _register(client, 'page@x.com')
c = auth_client(access)
r = c.get(f'{RESOURCES}?skill={ml.id}')
assert r.status_code == 200
# catalog fixture already adds 1 ML resource; + 12 extras = 13 total.
assert r.data['count'] == 13
assert 'results' in r.data
assert len(r.data['results']) == 13 # under default page_size=50