gapguide-api / tests /verify_claims.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
10.5 kB
"""Verification tests for the code-review fixes.
Each test encodes the *expected post-fix behavior* for one of the original
review claims. A passing test means the fix is in place. Intentionally kept
outside the default pytest collection glob (see pytest.ini) — invoke with
`pytest tests/verify_claims.py` when you want an audit trail.
"""
import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APIClient
from apps.progress.models import UserProgress
from apps.progress.services import apply_upgrade
from apps.resources.models import Resource, SkillResource
from apps.skills.models import Skill, UserSkill
User = get_user_model()
pytestmark = pytest.mark.django_db
REGISTER = '/api/auth/register/'
# ---------------------------------------------------------------------------
# FIX #1 — apply_upgrade is now idempotent per completion. One completion
# yields exactly one bump, then already_applied=True.
# ---------------------------------------------------------------------------
def test_fix_1_apply_upgrade_is_idempotent_per_completion():
user = User.objects.create_user(
username='claim1@x.com', email='claim1@x.com', password='pw', name='c1',
)
py = Skill.objects.create(
skill_name='Python', category='Programming', difficulty_level='BEGINNER',
)
r = Resource.objects.create(
title='R', provider='P', url='https://e.com/c1',
difficulty_level='BEGINNER', duration=10, type='DOCS', rating=4.0,
)
SkillResource.objects.create(skill=py, resource=r, relevance_score=1.0)
UserProgress.objects.create(
user=user, resource=r, status='COMPLETED', progress=100,
completed_at=timezone.now(),
)
results = [apply_upgrade(user, py.id) for _ in range(5)]
final = UserSkill.objects.get(user=user, skill=py).proficiency
# First call applies; all subsequent return already_applied=True.
assert final == 60, f"expected one-rung bump to 60, got {final}"
assert results[0]['applied'] is True
for later in results[1:]:
assert later['applied'] is False
assert later['already_applied'] is True
# ---------------------------------------------------------------------------
# FIX #2 — password validators are now enforced at registration.
# ---------------------------------------------------------------------------
def test_fix_2_short_password_rejected():
c = APIClient()
r = c.post(REGISTER, data={
'name': 'Weak', 'email': 'weak@x.com',
'password': 'abc', 'password_confirm': 'abc',
}, format='json')
assert r.status_code == 400
assert 'password' in r.data
def test_fix_2_common_password_rejected():
c = APIClient()
r = c.post(REGISTER, data={
'name': 'Common', 'email': 'common@x.com',
'password': 'password', 'password_confirm': 'password',
}, format='json')
assert r.status_code == 400
def test_fix_2_numeric_password_rejected():
c = APIClient()
r = c.post(REGISTER, data={
'name': 'Numeric', 'email': 'numeric@x.com',
'password': '12345678', 'password_confirm': '12345678',
}, format='json')
assert r.status_code == 400
def test_fix_2_strong_password_accepted():
c = APIClient()
r = c.post(REGISTER, data={
'name': 'Strong', 'email': 'strong@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r.status_code == 201
# ---------------------------------------------------------------------------
# FIX #3 — email uniqueness is now case-insensitive (normalized lowercase).
# Login accepts either case.
# ---------------------------------------------------------------------------
def test_fix_3_case_variant_duplicate_rejected():
c = APIClient()
r1 = c.post(REGISTER, data={
'name': 'A', 'email': 'DupCase@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r1.status_code == 201
# Stored as lowercase.
assert User.objects.get(email__iexact='dupcase@x.com').email == 'dupcase@x.com'
r2 = c.post(REGISTER, data={
'name': 'B', 'email': 'dupcase@x.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r2.status_code == 400
assert User.objects.filter(email__iexact='dupcase@x.com').count() == 1
def test_fix_3_login_case_insensitive():
c = APIClient()
c.post(REGISTER, data={
'name': 'Mixed', 'email': 'Mixed@X.com',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
r = c.post('/api/auth/login/', data={
'email': 'MIXED@x.COM', 'password': 'StrongPass123!',
}, format='json')
assert r.status_code == 200, r.data
# ---------------------------------------------------------------------------
# FIX #8 — resource list no longer N+1s on has_checkpoints. A single SQL
# statement (the Exists subquery) covers the whole page.
# ---------------------------------------------------------------------------
def test_fix_8_resource_list_no_n_plus_one_on_has_checkpoints():
from django.db import connection
from django.test.utils import CaptureQueriesContext
def _count_cp_queries(row_count: int) -> int:
skill = Skill.objects.create(
skill_name=f'S{row_count}', category='C', difficulty_level='BEGINNER',
)
for i in range(row_count):
r = Resource.objects.create(
title=f'R{row_count}_{i}', provider='P',
url=f'https://e.com/{row_count}_{i}',
difficulty_level='BEGINNER', duration=10, type='DOCS', rating=4.0,
)
SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0)
user = User.objects.create_user(
username=f'cp{row_count}@x.com', email=f'cp{row_count}@x.com',
password='pw', name='n',
)
c = APIClient()
c.force_authenticate(user=user)
with CaptureQueriesContext(connection) as ctx:
resp = c.get('/api/resources/')
assert resp.status_code == 200
return sum(
1 for q in ctx.captured_queries
if 'resources_resourcecheckpoint' in q['sql'].lower()
)
# N+1 signature = query count scales with row count. Exists() annotation
# folds the lookup into the main SELECT, so the count is constant.
queries_for_5 = _count_cp_queries(5)
queries_for_10 = _count_cp_queries(10)
assert queries_for_5 == queries_for_10, (
f"query count should not scale with row count; "
f"got {queries_for_5} for 5 rows and {queries_for_10} for 10 rows"
)
# And the total checkpoint-related query count should be tiny (≤ 1).
assert queries_for_5 <= 1, queries_for_5
# ---------------------------------------------------------------------------
# FIX #5 — ranking docstring arithmetic now matches _score output.
# ---------------------------------------------------------------------------
def test_fix_5_ranking_scores_match_docstring():
from apps.analysis.services import _score
docs_score = _score('DOCS', 'INTERMEDIATE', 'INTERMEDIATE', 1.0, 3.5)
video_score = _score('VIDEO', 'INTERMEDIATE', 'INTERMEDIATE', 0.3, 3.5)
assert abs(docs_score - 32.5) < 0.01
assert abs(video_score - 28.0) < 0.01
assert docs_score > video_score
# ---------------------------------------------------------------------------
# FIX #25 — re-POST of the same target role now bumps selected_at.
# ---------------------------------------------------------------------------
def test_fix_25_same_role_repost_bumps_selected_at():
from apps.roles.models import Role, UserTargetRole
from time import sleep
user = User.objects.create_user(
username='tr@x.com', email='tr@x.com', password='pw', name='tr',
)
role = Role.objects.create(role_name='R', industry='Tech', is_active=True)
c = APIClient()
c.force_authenticate(user=user)
c.post('/api/target-role/', {'role_id': role.id}, format='json')
first = UserTargetRole.objects.get(user=user, is_active=True)
first_ts = first.selected_at
sleep(0.05)
c.post('/api/target-role/', {'role_id': role.id}, format='json')
again = UserTargetRole.objects.get(user=user, is_active=True)
assert again.pk == first.pk # same row reused (no duplicate created)
assert again.selected_at > first_ts # timestamp refreshed
# ---------------------------------------------------------------------------
# FIX #6 / #7 — list endpoint shapes are explicit design choices.
# UserProgress and Roles are both intentionally unpaginated (bounded
# cardinality). Document the contract so the frontend doesn't need to handle
# two shapes.
# ---------------------------------------------------------------------------
def test_fix_6_progress_list_remains_plain_list():
user = User.objects.create_user(
username='pag@x.com', email='pag@x.com', password='pw', name='pag',
)
c = APIClient()
c.force_authenticate(user=user)
r = c.get('/api/progress/')
assert r.status_code == 200
assert isinstance(r.data, list)
def test_fix_7_roles_list_remains_plain_list():
from apps.roles.models import Role
user = User.objects.create_user(
username='rag@x.com', email='rag@x.com', password='pw', name='rag',
)
Role.objects.create(role_name='X', industry='Tech', is_active=True)
c = APIClient()
c.force_authenticate(user=user)
r = c.get('/api/roles/')
assert r.status_code == 200
assert isinstance(r.data, list)
# ---------------------------------------------------------------------------
# FIX (bonus) — only /api/recommendations/ is routed; the duplicate under
# /api/analysis/recommendations/ has been removed.
# ---------------------------------------------------------------------------
def test_fix_bonus_recommendations_not_duplicated():
user = User.objects.create_user(
username='dup@x.com', email='dup@x.com', password='pw', name='d',
)
c = APIClient()
c.force_authenticate(user=user)
# Canonical path still works (400 because no target role is set).
r = c.get('/api/recommendations/')
assert r.status_code in (200, 400)
# Duplicate path should be gone (404).
r_dup = c.get('/api/analysis/recommendations/')
assert r_dup.status_code == 404