scholarr/tests/unit/test_scholar_validators.py
Justin Visser 813da91608 fix(scholars): improve validation, fix delete, add status badges
- Extract scholar ID parsing to dedicated module with robust URL
  handling (trailing slashes, fragments, extra query params, encoded
  characters) and per-token error feedback showing why each invalid
  entry was skipped
- Wrap delete_scholar DB commit in IntegrityError handler so cascade
  failures return a proper API error instead of 500
- Add ScholarStatusBadges component showing Pending/Failed/Partial/OK
  badges on scholar rows based on baseline_completed and last_run_status
- Show Pending badge in ScholarSettingsModal for unscraped scholars
- Surface failed_count and partial_count in dashboard latest run summary
  and recent run list items
- Extend backend validator tests with edge cases (empty, whitespace,
  unicode, URL-as-ID)
- Add integration tests for DELETE 404, DELETE with cascade, and POST
  with corrupted scholar_id
- Add comprehensive frontend tests for parsing logic and component
  behavior
2026-03-07 14:05:39 +01:00

170 lines
6.7 KiB
Python

from __future__ import annotations
import os
import tempfile
import pytest
from app.services.scholars.constants import MAX_IMAGE_URL_LENGTH
from app.services.scholars.exceptions import ScholarServiceError
from app.services.scholars.uploads import (
_ensure_upload_root,
_resolve_upload_path,
_safe_remove_upload,
resolve_upload_file_path,
)
from app.services.scholars.validators import (
normalize_display_name,
normalize_profile_image_url,
validate_scholar_id,
)
class TestValidateScholarId:
def test_valid_12_char_id(self) -> None:
assert validate_scholar_id("ABCDEF123456") == "ABCDEF123456"
def test_valid_with_hyphens_and_underscores(self) -> None:
assert validate_scholar_id("AB-CD_EF1234") == "AB-CD_EF1234"
def test_strips_whitespace(self) -> None:
assert validate_scholar_id(" ABCDEF123456 ") == "ABCDEF123456"
def test_rejects_too_short(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABC123")
def test_rejects_too_long(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF1234567")
def test_rejects_special_characters(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF12345!")
def test_rejects_empty_string(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("")
def test_rejects_whitespace_only(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id(" ")
def test_rejects_embedded_spaces(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF 12345")
def test_rejects_tab_characters(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF\t12345")
def test_rejects_url_as_id(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("https://scholar.google.com/citations?user=ABCDEF123456")
def test_rejects_newline_in_id(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF\n12345")
def test_rejects_unicode_characters(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF12345\u00e9")
class TestNormalizeDisplayName:
def test_returns_stripped_name(self) -> None:
assert normalize_display_name(" John Doe ") == "John Doe"
def test_returns_none_for_empty(self) -> None:
assert normalize_display_name("") is None
def test_returns_none_for_whitespace_only(self) -> None:
assert normalize_display_name(" ") is None
def test_preserves_non_empty(self) -> None:
assert normalize_display_name("A") == "A"
class TestNormalizeProfileImageUrl:
def test_valid_https_url(self) -> None:
assert normalize_profile_image_url("https://example.com/img.png") == "https://example.com/img.png"
def test_valid_http_url(self) -> None:
assert normalize_profile_image_url("http://example.com/img.png") == "http://example.com/img.png"
def test_returns_none_for_none(self) -> None:
assert normalize_profile_image_url(None) is None
def test_returns_none_for_empty(self) -> None:
assert normalize_profile_image_url("") is None
def test_returns_none_for_whitespace(self) -> None:
assert normalize_profile_image_url(" ") is None
def test_rejects_ftp_scheme(self) -> None:
with pytest.raises(ScholarServiceError, match="http"):
normalize_profile_image_url("ftp://example.com/img.png")
def test_rejects_no_scheme(self) -> None:
with pytest.raises(ScholarServiceError, match="http"):
normalize_profile_image_url("example.com/img.png")
def test_rejects_url_too_long(self) -> None:
long_url = "https://example.com/" + "a" * MAX_IMAGE_URL_LENGTH
with pytest.raises(ScholarServiceError, match="characters or fewer"):
normalize_profile_image_url(long_url)
def test_accepts_url_at_max_length(self) -> None:
url = "https://example.com/" + "a" * (MAX_IMAGE_URL_LENGTH - len("https://example.com/"))
assert len(url) == MAX_IMAGE_URL_LENGTH
assert normalize_profile_image_url(url) == url
class TestUploadPathSafety:
def test_resolve_upload_path_rejects_traversal(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
with pytest.raises(ScholarServiceError, match="Invalid"):
_resolve_upload_path(root, "../../../etc/passwd")
def test_resolve_upload_path_accepts_valid_relative(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
result = _resolve_upload_path(root, "scholar_img.png")
assert result.name == "scholar_img.png"
assert root in result.parents or root == result.parent
def test_ensure_upload_root_creates_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
new_dir = os.path.join(tmpdir, "uploads", "images")
root = _ensure_upload_root(new_dir, create=True)
assert root.exists()
def test_safe_remove_upload_removes_existing_file(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
test_file = root / "test.png"
test_file.write_bytes(b"fake image")
assert test_file.exists()
_safe_remove_upload(root, "test.png")
assert not test_file.exists()
def test_safe_remove_upload_ignores_missing_file(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
_safe_remove_upload(root, "nonexistent.png")
def test_safe_remove_upload_ignores_none(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
_safe_remove_upload(root, None)
def test_safe_remove_upload_ignores_traversal_path(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = _ensure_upload_root(tmpdir, create=False)
_safe_remove_upload(root, "../../../etc/passwd")
def test_resolve_upload_file_path(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
result = resolve_upload_file_path(upload_dir=tmpdir, relative_path="img.png")
assert result.name == "img.png"