after audit
This commit is contained in:
parent
e8e20637e6
commit
a5165dc3ba
80 changed files with 8489 additions and 7302 deletions
|
|
@ -1,11 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.security import PasswordService
|
||||
|
||||
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
|
||||
SAFETY_STATE_KEYS = {
|
||||
"cooldown_active",
|
||||
"cooldown_reason",
|
||||
"cooldown_reason_label",
|
||||
"cooldown_until",
|
||||
"cooldown_remaining_seconds",
|
||||
"recommended_action",
|
||||
"counters",
|
||||
}
|
||||
SAFETY_COUNTER_KEYS = {
|
||||
"consecutive_blocked_runs",
|
||||
"consecutive_network_runs",
|
||||
"cooldown_entry_count",
|
||||
"blocked_start_count",
|
||||
"last_blocked_failure_count",
|
||||
"last_network_failure_count",
|
||||
"last_evaluated_run_id",
|
||||
}
|
||||
ACTIVE_RUN_STATUSES = {"running", "resolving"}
|
||||
|
||||
|
||||
def login_user(client: TestClient, *, email: str, password: str) -> None:
|
||||
bootstrap_response = client.get("/api/v1/auth/csrf")
|
||||
|
|
@ -54,3 +78,93 @@ async def insert_user(
|
|||
user_id = int(result.scalar_one())
|
||||
await db_session.commit()
|
||||
return user_id
|
||||
|
||||
|
||||
def api_bootstrap_csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
bootstrap_response = client.get("/api/v1/auth/csrf")
|
||||
assert bootstrap_response.status_code == 200
|
||||
payload = bootstrap_response.json()["data"]
|
||||
token = payload["csrf_token"]
|
||||
assert isinstance(token, str) and token
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def api_csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
me_response = client.get("/api/v1/auth/me")
|
||||
assert me_response.status_code == 200
|
||||
body = me_response.json()
|
||||
token = body["data"]["csrf_token"]
|
||||
assert isinstance(token, str) and token
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def regression_fixture(name: str) -> str:
|
||||
return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def assert_safety_state_contract(payload: dict[str, object]) -> None:
|
||||
assert set(payload.keys()) == SAFETY_STATE_KEYS
|
||||
counters = payload["counters"]
|
||||
assert isinstance(counters, dict)
|
||||
assert set(counters.keys()) == SAFETY_COUNTER_KEYS
|
||||
|
||||
|
||||
async def wait_for_run_complete(
|
||||
client: TestClient,
|
||||
run_id: int,
|
||||
*,
|
||||
max_retries: int = 300,
|
||||
poll_interval: float = 0.2,
|
||||
) -> dict:
|
||||
for _ in range(max_retries):
|
||||
await asyncio.sleep(poll_interval)
|
||||
r = client.get(f"/api/v1/runs/{run_id}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
if data["run"]["status"] not in ACTIVE_RUN_STATUSES:
|
||||
return data
|
||||
r = client.get(f"/api/v1/runs/{run_id}")
|
||||
return r.json()["data"]
|
||||
|
||||
|
||||
async def seed_publication_link_for_user(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
scholar_id: str,
|
||||
title: str,
|
||||
fingerprint: str,
|
||||
) -> tuple[int, int]:
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "scholar_id": scholar_id, "display_name": scholar_id},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
publication_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 4)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"fingerprint": fingerprint, "title_raw": title, "title_normalized": title.lower()},
|
||||
)
|
||||
publication_id = int(publication_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES (:scholar_profile_id, :publication_id, false)
|
||||
"""
|
||||
),
|
||||
{"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
|
||||
)
|
||||
await db_session.commit()
|
||||
return scholar_profile_id, publication_id
|
||||
|
|
|
|||
576
tests/integration/test_api_admin.py
Normal file
576
tests/integration/test_api_admin.py
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from app.settings import settings
|
||||
from tests.integration.helpers import (
|
||||
api_bootstrap_csrf_headers,
|
||||
api_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
seed_publication_link_for_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_user_management_endpoints(db_session: AsyncSession) -> None:
|
||||
admin_user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-admin@example.com",
|
||||
password="admin-password",
|
||||
is_admin=True,
|
||||
)
|
||||
target_user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-target@example.com",
|
||||
password="target-password",
|
||||
is_admin=False,
|
||||
)
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-member@example.com",
|
||||
password="member-password",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
list_response = client.get("/api/v1/admin/users")
|
||||
assert list_response.status_code == 200
|
||||
users = list_response.json()["data"]["users"]
|
||||
assert any(item["email"] == "api-target@example.com" for item in users)
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={
|
||||
"email": "api-created@example.com",
|
||||
"password": "created-password",
|
||||
"is_admin": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created_user = create_response.json()["data"]
|
||||
assert created_user["email"] == "api-created@example.com"
|
||||
created_user_id = int(created_user["id"])
|
||||
|
||||
deactivate_response = client.patch(
|
||||
f"/api/v1/admin/users/{target_user_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert deactivate_response.status_code == 200
|
||||
assert deactivate_response.json()["data"]["is_active"] is False
|
||||
|
||||
reactivate_response = client.patch(
|
||||
f"/api/v1/admin/users/{target_user_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert reactivate_response.status_code == 200
|
||||
assert reactivate_response.json()["data"]["is_active"] is True
|
||||
|
||||
reset_response = client.post(
|
||||
f"/api/v1/admin/users/{target_user_id}/reset-password",
|
||||
json={"new_password": "target-password-updated"},
|
||||
headers=headers,
|
||||
)
|
||||
assert reset_response.status_code == 200
|
||||
assert "Password reset" in reset_response.json()["data"]["message"]
|
||||
|
||||
self_deactivate = client.patch(
|
||||
f"/api/v1/admin/users/{admin_user_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert self_deactivate.status_code == 400
|
||||
assert self_deactivate.json()["error"]["code"] == "cannot_deactivate_self"
|
||||
|
||||
logout_response = client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert logout_response.status_code == 200
|
||||
|
||||
target_headers = api_bootstrap_csrf_headers(client)
|
||||
target_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-target@example.com", "password": "target-password-updated"},
|
||||
headers=target_headers,
|
||||
)
|
||||
assert target_login.status_code == 200
|
||||
|
||||
non_admin_headers = {"X-CSRF-Token": target_login.json()["data"]["csrf_token"]}
|
||||
forbidden_response = client.get("/api/v1/admin/users")
|
||||
assert forbidden_response.status_code == 403
|
||||
assert forbidden_response.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_create = client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={
|
||||
"email": "should-not-work@example.com",
|
||||
"password": "password-123",
|
||||
"is_admin": False,
|
||||
},
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_create.status_code == 403
|
||||
|
||||
created_exists = await db_session.execute(
|
||||
text("SELECT COUNT(*) FROM users WHERE id = :user_id"),
|
||||
{"user_id": created_user_id},
|
||||
)
|
||||
assert created_exists.scalar_one() == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_scholar_http_settings_endpoints(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-admin-http@example.com",
|
||||
password="admin-password",
|
||||
is_admin=True,
|
||||
)
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-member-http@example.com",
|
||||
password="member-password",
|
||||
is_admin=False,
|
||||
)
|
||||
previous_user_agent = settings.scholar_http_user_agent
|
||||
previous_rotate = settings.scholar_http_rotate_user_agent
|
||||
previous_accept_language = settings.scholar_http_accept_language
|
||||
previous_cookie = settings.scholar_http_cookie
|
||||
try:
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-http@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
read_response = client.get("/api/v1/admin/settings/scholar-http")
|
||||
assert read_response.status_code == 200
|
||||
|
||||
update_response = client.put(
|
||||
"/api/v1/admin/settings/scholar-http",
|
||||
json={
|
||||
"user_agent": "Mozilla/5.0 Test Runner",
|
||||
"rotate_user_agent": False,
|
||||
"accept_language": "en-US,en;q=0.8",
|
||||
"cookie": "SID=test-cookie",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
payload = update_response.json()["data"]
|
||||
assert payload["user_agent"] == "Mozilla/5.0 Test Runner"
|
||||
assert payload["cookie"] == "SID=test-cookie"
|
||||
assert settings.scholar_http_user_agent == "Mozilla/5.0 Test Runner"
|
||||
|
||||
client.post("/api/v1/auth/logout", headers=headers)
|
||||
login_user(client, email="api-member-http@example.com", password="member-password")
|
||||
forbidden_response = client.get("/api/v1/admin/settings/scholar-http")
|
||||
assert forbidden_response.status_code == 403
|
||||
finally:
|
||||
object.__setattr__(settings, "scholar_http_user_agent", previous_user_agent)
|
||||
object.__setattr__(settings, "scholar_http_rotate_user_agent", previous_rotate)
|
||||
object.__setattr__(settings, "scholar_http_accept_language", previous_accept_language)
|
||||
object.__setattr__(settings, "scholar_http_cookie", previous_cookie)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="api-admin-dbops@example.com", password="admin-password", is_admin=True)
|
||||
target_user_id = await insert_user(db_session, email="api-dbops-target@example.com", password="target-password")
|
||||
_scholar_id, publication_id = await seed_publication_link_for_user(
|
||||
db_session,
|
||||
user_id=target_user_id,
|
||||
scholar_id="dbopsTarget01",
|
||||
title="DB Ops Target Paper",
|
||||
fingerprint=f"{(target_user_id + 31):064x}",
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-dbops@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
integrity_response = client.get("/api/v1/admin/db/integrity")
|
||||
assert integrity_response.status_code == 200
|
||||
integrity_payload = integrity_response.json()["data"]
|
||||
assert integrity_payload["status"] in {"ok", "warning", "failed"}
|
||||
assert any(check["name"] == "missing_pdf_url" for check in integrity_payload["checks"])
|
||||
|
||||
repair_response = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-links",
|
||||
json={"user_id": target_user_id, "dry_run": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert repair_response.status_code == 200
|
||||
repair_data = repair_response.json()["data"]
|
||||
assert repair_data["status"] == "completed"
|
||||
assert bool(repair_data["summary"]["dry_run"]) is True
|
||||
job_id = int(repair_data["job_id"])
|
||||
|
||||
jobs_response = client.get("/api/v1/admin/db/repair-jobs?limit=10")
|
||||
assert jobs_response.status_code == 200
|
||||
jobs = jobs_response.json()["data"]["jobs"]
|
||||
assert any(int(job["id"]) == job_id for job in jobs)
|
||||
|
||||
pdf_queue_response = client.get("/api/v1/admin/db/pdf-queue?limit=20")
|
||||
assert pdf_queue_response.status_code == 200
|
||||
pdf_queue_payload = pdf_queue_response.json()["data"]
|
||||
assert isinstance(pdf_queue_payload["items"], list)
|
||||
assert int(pdf_queue_payload["page"]) == 1
|
||||
assert int(pdf_queue_payload["page_size"]) == 20
|
||||
assert int(pdf_queue_payload["total_count"]) >= 0
|
||||
assert isinstance(pdf_queue_payload["has_next"], bool)
|
||||
assert isinstance(pdf_queue_payload["has_prev"], bool)
|
||||
page_two_response = client.get("/api/v1/admin/db/pdf-queue?page=2&page_size=1")
|
||||
assert page_two_response.status_code == 200
|
||||
page_two_payload = page_two_response.json()["data"]
|
||||
assert int(page_two_payload["page"]) == 2
|
||||
assert int(page_two_payload["page_size"]) == 1
|
||||
assert page_two_payload["has_prev"] is True
|
||||
untracked_response = client.get("/api/v1/admin/db/pdf-queue?limit=20&status=untracked")
|
||||
assert untracked_response.status_code == 200
|
||||
assert any(item["status"] == "untracked" for item in untracked_response.json()["data"]["items"])
|
||||
|
||||
link_count_result = await db_session.execute(
|
||||
text("SELECT count(*) FROM scholar_publications WHERE publication_id = :publication_id"),
|
||||
{"publication_id": publication_id},
|
||||
)
|
||||
assert int(link_count_result.scalar_one()) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_pdf_queue_requeue_endpoint(
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
await insert_user(db_session, email="api-admin-pdf@example.com", password="admin-password", is_admin=True)
|
||||
target_user_id = await insert_user(db_session, email="api-pdf-target@example.com", password="target-password")
|
||||
_scholar_id, publication_id = await seed_publication_link_for_user(
|
||||
db_session,
|
||||
user_id=target_user_id,
|
||||
scholar_id="dbopsPdf01",
|
||||
title="PDF Queue Target",
|
||||
fingerprint=f"{(target_user_id + 73):064x}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.publications.pdf_queue.schedule_rows",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-pdf@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
first_response = client.post(
|
||||
f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
|
||||
headers=headers,
|
||||
)
|
||||
assert first_response.status_code == 200
|
||||
first_data = first_response.json()["data"]
|
||||
assert first_data["publication_id"] == publication_id
|
||||
assert first_data["queued"] is True
|
||||
assert first_data["status"] == "queued"
|
||||
|
||||
second_response = client.post(
|
||||
f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
|
||||
headers=headers,
|
||||
)
|
||||
assert second_response.status_code == 200
|
||||
second_data = second_response.json()["data"]
|
||||
assert second_data["queued"] is False
|
||||
assert second_data["status"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_pdf_queue_requeue_all_endpoint(
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
await insert_user(db_session, email="api-admin-pdf-all@example.com", password="admin-password", is_admin=True)
|
||||
target_user_id = await insert_user(db_session, email="api-pdf-all-target@example.com", password="target-password")
|
||||
_scholar_id, _publication_id = await seed_publication_link_for_user(
|
||||
db_session,
|
||||
user_id=target_user_id,
|
||||
scholar_id="dbopsPdfAll01",
|
||||
title="PDF Queue All Target",
|
||||
fingerprint=f"{(target_user_id + 83):064x}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.publications.pdf_queue.schedule_rows",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-pdf-all@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/admin/db/pdf-queue/requeue-all?limit=500",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert int(payload["requested_count"]) >= 1
|
||||
assert int(payload["queued_count"]) >= 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="api-admin-dbops2@example.com", password="admin-password", is_admin=True)
|
||||
member_user_id = await insert_user(db_session, email="api-dbops-member@example.com", password="member-password")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-dbops-member@example.com", password="member-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
forbidden_integrity = client.get("/api/v1/admin/db/integrity")
|
||||
assert forbidden_integrity.status_code == 403
|
||||
assert forbidden_integrity.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
|
||||
assert forbidden_pdf_queue.status_code == 403
|
||||
assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_requeue = client.post(
|
||||
"/api/v1/admin/db/pdf-queue/1/requeue",
|
||||
headers=headers,
|
||||
)
|
||||
assert forbidden_requeue.status_code == 403
|
||||
assert forbidden_requeue.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_requeue_all = client.post(
|
||||
"/api/v1/admin/db/pdf-queue/requeue-all?limit=100",
|
||||
headers=headers,
|
||||
)
|
||||
assert forbidden_requeue_all.status_code == 403
|
||||
assert forbidden_requeue_all.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_repair = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-links",
|
||||
json={"user_id": member_user_id, "dry_run": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert forbidden_repair.status_code == 403
|
||||
assert forbidden_repair.json()["error"]["code"] == "forbidden"
|
||||
|
||||
forbidden_near_duplicate = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-near-duplicates",
|
||||
json={"dry_run": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert forbidden_near_duplicate.status_code == 403
|
||||
assert forbidden_near_duplicate.json()["error"]["code"] == "forbidden"
|
||||
|
||||
admin_headers = api_bootstrap_csrf_headers(client)
|
||||
admin_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-admin-dbops2@example.com", "password": "admin-password"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert admin_login.status_code == 200
|
||||
post_login_headers = {"X-CSRF-Token": admin_login.json()["data"]["csrf_token"]}
|
||||
invalid_scope = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-links",
|
||||
json={"user_id": member_user_id, "dry_run": True},
|
||||
headers=post_login_headers,
|
||||
)
|
||||
assert invalid_scope.status_code == 400
|
||||
assert invalid_scope.json()["error"]["code"] == "invalid_repair_scope"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_all_users_apply_requires_confirmation(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="api-admin-dbops3@example.com", password="admin-password", is_admin=True)
|
||||
first_user_id = await insert_user(db_session, email="api-dbops-a@example.com", password="user-password")
|
||||
second_user_id = await insert_user(db_session, email="api-dbops-b@example.com", password="user-password")
|
||||
await seed_publication_link_for_user(
|
||||
db_session,
|
||||
user_id=first_user_id,
|
||||
scholar_id="dbopsAll01",
|
||||
title="DB Ops All User Paper One",
|
||||
fingerprint=f"{(first_user_id + 61):064x}",
|
||||
)
|
||||
await seed_publication_link_for_user(
|
||||
db_session,
|
||||
user_id=second_user_id,
|
||||
scholar_id="dbopsAll02",
|
||||
title="DB Ops All User Paper Two",
|
||||
fingerprint=f"{(second_user_id + 71):064x}",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-dbops3@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
missing_confirmation = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-links",
|
||||
json={"scope_mode": "all_users", "dry_run": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert missing_confirmation.status_code == 422
|
||||
assert "confirmation_text" in str(missing_confirmation.json())
|
||||
|
||||
apply_response = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-links",
|
||||
json={
|
||||
"scope_mode": "all_users",
|
||||
"dry_run": False,
|
||||
"confirmation_text": "REPAIR ALL USERS",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert apply_response.status_code == 200
|
||||
apply_data = apply_response.json()["data"]
|
||||
assert apply_data["status"] == "completed"
|
||||
assert apply_data["scope"]["scope_mode"] == "all_users"
|
||||
assert int(apply_data["summary"]["links_deleted"]) >= 2
|
||||
|
||||
remaining_links = await db_session.execute(text("SELECT count(*) FROM scholar_publications"))
|
||||
assert int(remaining_links.scalar_one()) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_admin_dbops_near_duplicate_repair_scan_and_apply(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-admin-near-dup@example.com",
|
||||
password="admin-password",
|
||||
is_admin=True,
|
||||
)
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-near-dup-target@example.com",
|
||||
password="user-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "nearDupScholar01",
|
||||
"display_name": "Near Dup Target",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
first_pub = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 2014, 100)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1201):064x}",
|
||||
"title_raw": "Adam: A method for stochastic optimization",
|
||||
"title_normalized": "adam a method for stochastic optimization",
|
||||
},
|
||||
)
|
||||
first_publication_id = int(first_pub.scalar_one())
|
||||
second_pub = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 2015, 10)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1202):064x}",
|
||||
"title_raw": "†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent.",
|
||||
"title_normalized": "adam method for stochastic optimization",
|
||||
},
|
||||
)
|
||||
second_publication_id = int(second_pub.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES (:scholar_profile_id, :first_publication_id, false),
|
||||
(:scholar_profile_id, :second_publication_id, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"first_publication_id": first_publication_id,
|
||||
"second_publication_id": second_publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-admin-near-dup@example.com", password="admin-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
scan_response = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-near-duplicates",
|
||||
json={"dry_run": True, "max_clusters": 20},
|
||||
headers=headers,
|
||||
)
|
||||
assert scan_response.status_code == 200
|
||||
scan_data = scan_response.json()["data"]
|
||||
assert scan_data["status"] == "completed"
|
||||
assert int(scan_data["summary"]["candidate_cluster_count"]) >= 1
|
||||
assert len(scan_data["clusters"]) >= 1
|
||||
selected_key = str(scan_data["clusters"][0]["cluster_key"])
|
||||
|
||||
missing_confirmation = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-near-duplicates",
|
||||
json={"dry_run": False, "selected_cluster_keys": [selected_key]},
|
||||
headers=headers,
|
||||
)
|
||||
assert missing_confirmation.status_code == 422
|
||||
assert "confirmation_text" in str(missing_confirmation.json())
|
||||
|
||||
apply_response = client.post(
|
||||
"/api/v1/admin/db/repairs/publication-near-duplicates",
|
||||
json={
|
||||
"dry_run": False,
|
||||
"selected_cluster_keys": [selected_key],
|
||||
"confirmation_text": "MERGE SELECTED DUPLICATES",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert apply_response.status_code == 200
|
||||
apply_data = apply_response.json()["data"]
|
||||
assert apply_data["status"] == "completed"
|
||||
assert int(apply_data["summary"]["merged_publications"]) >= 1
|
||||
|
||||
remaining = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM publications
|
||||
WHERE id IN (:first_publication_id, :second_publication_id)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"first_publication_id": first_publication_id,
|
||||
"second_publication_id": second_publication_id,
|
||||
},
|
||||
)
|
||||
assert int(remaining.scalar_one()) == 1
|
||||
182
tests/integration/test_api_auth.py
Normal file
182
tests/integration/test_api_auth.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from tests.integration.helpers import (
|
||||
api_bootstrap_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_me_requires_authentication() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = response.json()
|
||||
assert payload["error"]["code"] == "auth_required"
|
||||
assert payload["error"]["message"] == "Authentication required."
|
||||
assert "request_id" in payload["meta"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_envelope_contract_for_success_and_csrf_error() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
success_response = client.get("/api/v1/auth/csrf")
|
||||
assert success_response.status_code == 200
|
||||
success_payload = success_response.json()
|
||||
assert set(success_payload.keys()) == {"data", "meta"}
|
||||
assert isinstance(success_payload["data"]["csrf_token"], str)
|
||||
assert set(success_payload["meta"].keys()) == {"request_id"}
|
||||
|
||||
error_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "missing-csrf@example.com", "password": "irrelevant"},
|
||||
)
|
||||
assert error_response.status_code == 403
|
||||
error_payload = error_response.json()
|
||||
assert set(error_payload.keys()) == {"error", "meta"}
|
||||
assert set(error_payload["error"].keys()) == {"code", "message", "details"}
|
||||
assert error_payload["error"]["code"] == "csrf_invalid"
|
||||
assert error_payload["error"]["details"] is None
|
||||
assert set(error_payload["meta"].keys()) == {"request_id"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_csrf_bootstrap_returns_token_for_anonymous_and_authenticated(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-bootstrap@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
anonymous_response = client.get("/api/v1/auth/csrf")
|
||||
assert anonymous_response.status_code == 200
|
||||
anonymous_payload = anonymous_response.json()["data"]
|
||||
assert isinstance(anonymous_payload["csrf_token"], str)
|
||||
assert anonymous_payload["authenticated"] is False
|
||||
|
||||
login_user(client, email="api-bootstrap@example.com", password="api-password")
|
||||
authenticated_response = client.get("/api/v1/auth/csrf")
|
||||
assert authenticated_response.status_code == 200
|
||||
authenticated_payload = authenticated_response.json()["data"]
|
||||
assert isinstance(authenticated_payload["csrf_token"], str)
|
||||
assert authenticated_payload["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_me_returns_user_and_csrf_token(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-me@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-me@example.com", password="api-password")
|
||||
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["authenticated"] is True
|
||||
assert payload["data"]["user"]["email"] == "api-me@example.com"
|
||||
assert isinstance(payload["data"]["csrf_token"], str)
|
||||
assert payload["meta"]["request_id"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_login_and_change_password_flow(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-login@example.com",
|
||||
password="old-password",
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
missing_csrf = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-login@example.com", "password": "old-password"},
|
||||
)
|
||||
assert missing_csrf.status_code == 403
|
||||
assert missing_csrf.json()["error"]["code"] == "csrf_missing"
|
||||
|
||||
login_headers = api_bootstrap_csrf_headers(client)
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-login@example.com", "password": "old-password"},
|
||||
headers=login_headers,
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
login_payload = login_response.json()["data"]
|
||||
assert login_payload["authenticated"] is True
|
||||
assert login_payload["user"]["email"] == "api-login@example.com"
|
||||
assert isinstance(login_payload["csrf_token"], str)
|
||||
|
||||
bad_change_response = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={
|
||||
"current_password": "not-correct",
|
||||
"new_password": "new-password",
|
||||
"confirm_password": "new-password",
|
||||
},
|
||||
headers={"X-CSRF-Token": login_payload["csrf_token"]},
|
||||
)
|
||||
assert bad_change_response.status_code == 400
|
||||
assert bad_change_response.json()["error"]["code"] == "invalid_current_password"
|
||||
|
||||
change_response = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={
|
||||
"current_password": "old-password",
|
||||
"new_password": "new-password",
|
||||
"confirm_password": "new-password",
|
||||
},
|
||||
headers={"X-CSRF-Token": login_payload["csrf_token"]},
|
||||
)
|
||||
assert change_response.status_code == 200
|
||||
assert change_response.json()["data"]["message"] == "Password updated successfully."
|
||||
|
||||
logout_response = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"X-CSRF-Token": login_payload["csrf_token"]},
|
||||
)
|
||||
assert logout_response.status_code == 200
|
||||
|
||||
relogin_headers = api_bootstrap_csrf_headers(client)
|
||||
old_password_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-login@example.com", "password": "old-password"},
|
||||
headers=relogin_headers,
|
||||
)
|
||||
assert old_password_login.status_code == 401
|
||||
assert old_password_login.json()["error"]["code"] == "invalid_credentials"
|
||||
|
||||
fresh_headers = api_bootstrap_csrf_headers(client)
|
||||
new_password_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "api-login@example.com", "password": "new-password"},
|
||||
headers=fresh_headers,
|
||||
)
|
||||
assert new_password_login.status_code == 200
|
||||
assert new_password_login.json()["data"]["authenticated"] is True
|
||||
573
tests/integration/test_api_publications.py
Normal file
573
tests/integration/test_api_publications.py
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_list_and_mark_read(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "abcDEF123456",
|
||||
"display_name": "Publication Owner",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
|
||||
publication_a = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (
|
||||
fingerprint_sha256,
|
||||
title_raw,
|
||||
title_normalized,
|
||||
citation_count,
|
||||
pdf_url
|
||||
)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 10, :pdf_url)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{user_id:064x}",
|
||||
"title_raw": "Paper A",
|
||||
"title_normalized": "paper a",
|
||||
"pdf_url": "https://example.org/paper-a.pdf",
|
||||
},
|
||||
)
|
||||
publication_a_id = int(publication_a.scalar_one())
|
||||
|
||||
publication_b = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 4)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1):064x}",
|
||||
"title_raw": "Paper B",
|
||||
"title_normalized": "paper b",
|
||||
},
|
||||
)
|
||||
publication_b_id = int(publication_b.scalar_one())
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES
|
||||
(:scholar_profile_id, :publication_a_id, false),
|
||||
(:scholar_profile_id, :publication_b_id, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_a_id": publication_a_id,
|
||||
"publication_b_id": publication_b_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
list_response = client.get("/api/v1/publications?mode=all")
|
||||
assert list_response.status_code == 200
|
||||
data = list_response.json()["data"]
|
||||
assert data["mode"] == "all"
|
||||
assert data["favorite_only"] is False
|
||||
assert data["total_count"] == 2
|
||||
assert data["unread_count"] == 2
|
||||
assert data["favorites_count"] == 0
|
||||
assert data["latest_count"] == 0
|
||||
assert data["new_count"] == data["latest_count"]
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 100
|
||||
assert data["has_prev"] is False
|
||||
assert data["has_next"] is False
|
||||
assert isinstance(data["publications"], list)
|
||||
assert len(data["publications"]) == 2
|
||||
assert all(bool(item["is_favorite"]) is False for item in data["publications"])
|
||||
pdf_urls = {item["title"]: item["pdf_url"] for item in data["publications"]}
|
||||
assert pdf_urls["Paper A"] == "https://example.org/paper-a.pdf"
|
||||
assert pdf_urls["Paper B"] is None
|
||||
|
||||
latest_response = client.get("/api/v1/publications?mode=latest")
|
||||
assert latest_response.status_code == 200
|
||||
latest_data = latest_response.json()["data"]
|
||||
assert latest_data["mode"] == "latest"
|
||||
assert latest_data["latest_count"] == 0
|
||||
|
||||
unread_response = client.get("/api/v1/publications?mode=unread")
|
||||
assert unread_response.status_code == 200
|
||||
unread_data = unread_response.json()["data"]
|
||||
assert unread_data["mode"] == "unread"
|
||||
assert unread_data["unread_count"] == 2
|
||||
|
||||
alias_response = client.get("/api/v1/publications?mode=new")
|
||||
assert alias_response.status_code == 200
|
||||
alias_data = alias_response.json()["data"]
|
||||
assert alias_data["mode"] == "latest"
|
||||
assert alias_data["latest_count"] == latest_data["latest_count"]
|
||||
assert alias_data["publications"] == latest_data["publications"]
|
||||
|
||||
mark_selected_response = client.post(
|
||||
"/api/v1/publications/mark-read",
|
||||
json={
|
||||
"selections": [
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_a_id,
|
||||
}
|
||||
]
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert mark_selected_response.status_code == 200
|
||||
assert mark_selected_response.json()["data"]["requested_count"] == 1
|
||||
assert mark_selected_response.json()["data"]["updated_count"] == 1
|
||||
|
||||
read_state = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT publication_id, is_read
|
||||
FROM scholar_publications
|
||||
WHERE scholar_profile_id = :scholar_profile_id
|
||||
ORDER BY publication_id
|
||||
"""
|
||||
),
|
||||
{"scholar_profile_id": scholar_profile_id},
|
||||
)
|
||||
states = {int(row[0]): bool(row[1]) for row in read_state.all()}
|
||||
assert states[publication_a_id] is True
|
||||
assert states[publication_b_id] is False
|
||||
|
||||
mark_response = client.post("/api/v1/publications/mark-all-read", headers=headers)
|
||||
assert mark_response.status_code == 200
|
||||
assert mark_response.json()["data"]["updated_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_list_supports_pagination(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-paging@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "pagingScholar01",
|
||||
"display_name": "Paging Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
|
||||
publication_ids: list[int] = []
|
||||
for index in range(3):
|
||||
created = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 500 + index):064x}",
|
||||
"title_raw": f"Paged Paper {index}",
|
||||
"title_normalized": f"paged paper {index}",
|
||||
},
|
||||
)
|
||||
publication_ids.append(int(created.scalar_one()))
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
|
||||
VALUES
|
||||
(:scholar_profile_id, :publication_1, false, false),
|
||||
(:scholar_profile_id, :publication_2, false, false),
|
||||
(:scholar_profile_id, :publication_3, false, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_1": publication_ids[0],
|
||||
"publication_2": publication_ids[1],
|
||||
"publication_3": publication_ids[2],
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-paging@example.com", password="api-password")
|
||||
|
||||
first_page = client.get("/api/v1/publications?mode=all&page=1&page_size=2")
|
||||
assert first_page.status_code == 200
|
||||
first_data = first_page.json()["data"]
|
||||
assert first_data["total_count"] == 3
|
||||
assert first_data["page"] == 1
|
||||
assert first_data["page_size"] == 2
|
||||
assert first_data["has_prev"] is False
|
||||
assert first_data["has_next"] is True
|
||||
assert len(first_data["publications"]) == 2
|
||||
|
||||
second_page = client.get("/api/v1/publications?mode=all&page=2&page_size=2")
|
||||
assert second_page.status_code == 200
|
||||
second_data = second_page.json()["data"]
|
||||
assert second_data["total_count"] == 3
|
||||
assert second_data["page"] == 2
|
||||
assert second_data["page_size"] == 2
|
||||
assert second_data["has_prev"] is True
|
||||
assert second_data["has_next"] is False
|
||||
assert len(second_data["publications"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_search_pagination_uses_filtered_total_count(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-search-page@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "searchPagingScholar01",
|
||||
"display_name": "Search Paging Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
titles = ["Alpha Optimization", "Alpha Learning", "Beta Methods"]
|
||||
publication_ids: list[int] = []
|
||||
for index, title in enumerate(titles):
|
||||
created = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 900 + index):064x}",
|
||||
"title_raw": title,
|
||||
"title_normalized": title.lower(),
|
||||
},
|
||||
)
|
||||
publication_ids.append(int(created.scalar_one()))
|
||||
for publication_id in publication_ids:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
|
||||
VALUES (:scholar_profile_id, :publication_id, false, false)
|
||||
"""
|
||||
),
|
||||
{"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-search-page@example.com", password="api-password")
|
||||
|
||||
response = client.get("/api/v1/publications?mode=all&page=1&page_size=2&search=alpha")
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert int(data["total_count"]) == 2
|
||||
assert data["has_next"] is False
|
||||
assert len(data["publications"]) == 2
|
||||
assert all("alpha" in str(item["title"]).lower() for item in data["publications"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_supports_sort_by_pdf_status(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-pdf-sort@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "pdfSortScholar01",
|
||||
"display_name": "PDF Sort Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
|
||||
resolved_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count, pdf_url)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1, :pdf_url)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1300):064x}",
|
||||
"title_raw": "Resolved PDF",
|
||||
"title_normalized": "resolved pdf",
|
||||
"pdf_url": "https://example.org/resolved.pdf",
|
||||
},
|
||||
)
|
||||
resolved_publication_id = int(resolved_result.scalar_one())
|
||||
queued_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1301):064x}",
|
||||
"title_raw": "Queued PDF",
|
||||
"title_normalized": "queued pdf",
|
||||
},
|
||||
)
|
||||
queued_publication_id = int(queued_result.scalar_one())
|
||||
failed_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 1302):064x}",
|
||||
"title_raw": "Failed PDF",
|
||||
"title_normalized": "failed pdf",
|
||||
},
|
||||
)
|
||||
failed_publication_id = int(failed_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
|
||||
VALUES
|
||||
(:scholar_profile_id, :resolved_publication_id, false, false),
|
||||
(:scholar_profile_id, :queued_publication_id, false, false),
|
||||
(:scholar_profile_id, :failed_publication_id, false, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"resolved_publication_id": resolved_publication_id,
|
||||
"queued_publication_id": queued_publication_id,
|
||||
"failed_publication_id": failed_publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publication_pdf_jobs (publication_id, status, attempt_count)
|
||||
VALUES (:queued_publication_id, 'queued', 1),
|
||||
(:failed_publication_id, 'failed', 2)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"queued_publication_id": queued_publication_id,
|
||||
"failed_publication_id": failed_publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-pdf-sort@example.com", password="api-password")
|
||||
|
||||
response = client.get("/api/v1/publications?mode=all&sort_by=pdf_status&sort_dir=desc")
|
||||
assert response.status_code == 200
|
||||
publications = response.json()["data"]["publications"]
|
||||
publication_ids = [int(item["publication_id"]) for item in publications]
|
||||
assert publication_ids[0] == resolved_publication_id
|
||||
assert publication_ids[-1] == failed_publication_id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_favorite_toggle_and_filter(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-favorites@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "favoriteScholar01",
|
||||
"display_name": "Favorite Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
publication_a_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 9)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 101):064x}",
|
||||
"title_raw": "Favorite Target",
|
||||
"title_normalized": "favorite target",
|
||||
},
|
||||
)
|
||||
publication_a_id = int(publication_a_result.scalar_one())
|
||||
publication_b_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 2)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 102):064x}",
|
||||
"title_raw": "Not Favorite",
|
||||
"title_normalized": "not favorite",
|
||||
},
|
||||
)
|
||||
publication_b_id = int(publication_b_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
|
||||
VALUES
|
||||
(:scholar_profile_id, :publication_a_id, false, false),
|
||||
(:scholar_profile_id, :publication_b_id, false, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_a_id": publication_a_id,
|
||||
"publication_b_id": publication_b_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-favorites@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
set_response = client.post(
|
||||
f"/api/v1/publications/{publication_a_id}/favorite",
|
||||
json={"scholar_profile_id": scholar_profile_id, "is_favorite": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert set_response.status_code == 200
|
||||
set_data = set_response.json()["data"]
|
||||
assert set_data["publication"]["publication_id"] == publication_a_id
|
||||
assert set_data["publication"]["is_favorite"] is True
|
||||
|
||||
favorite_only_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
|
||||
assert favorite_only_response.status_code == 200
|
||||
favorite_only_data = favorite_only_response.json()["data"]
|
||||
assert favorite_only_data["favorite_only"] is True
|
||||
assert favorite_only_data["favorites_count"] == 1
|
||||
assert favorite_only_data["total_count"] == 1
|
||||
assert len(favorite_only_data["publications"]) == 1
|
||||
assert int(favorite_only_data["publications"][0]["publication_id"]) == publication_a_id
|
||||
|
||||
clear_response = client.post(
|
||||
f"/api/v1/publications/{publication_a_id}/favorite",
|
||||
json={"scholar_profile_id": scholar_profile_id, "is_favorite": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert clear_response.status_code == 200
|
||||
clear_data = clear_response.json()["data"]
|
||||
assert clear_data["publication"]["publication_id"] == publication_a_id
|
||||
assert clear_data["publication"]["is_favorite"] is False
|
||||
|
||||
favorite_only_after_clear_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
|
||||
assert favorite_only_after_clear_response.status_code == 200
|
||||
favorite_only_after_clear_data = favorite_only_after_clear_response.json()["data"]
|
||||
assert favorite_only_after_clear_data["favorites_count"] == 0
|
||||
assert favorite_only_after_clear_data["total_count"] == 0
|
||||
assert favorite_only_after_clear_data["publications"] == []
|
||||
|
||||
favorite_state_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT is_favorite
|
||||
FROM scholar_publications
|
||||
WHERE scholar_profile_id = :scholar_profile_id
|
||||
AND publication_id = :publication_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_a_id,
|
||||
},
|
||||
)
|
||||
assert bool(favorite_state_result.scalar_one()) is False
|
||||
337
tests/integration/test_api_publications_advanced.py
Normal file
337
tests/integration/test_api_publications_advanced.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from app.services.publications.types import PublicationListItem
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_list_schedules_background_enrichment(
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-bg@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "background111",
|
||||
"display_name": "Background Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
publication_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 3)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 17):064x}",
|
||||
"title_raw": "Background Target",
|
||||
"title_normalized": "background target",
|
||||
},
|
||||
)
|
||||
publication_id = int(publication_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES (:scholar_profile_id, :publication_id, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def _fake_schedule(db_session, *, user_id: int, request_email: str | None, items, max_items: int):
|
||||
assert db_session is not None
|
||||
assert user_id > 0
|
||||
assert request_email == "api-pubs-bg@example.com"
|
||||
assert int(max_items) > 0
|
||||
assert any(int(item.publication_id) == publication_id for item in items)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.publications.application.schedule_missing_pdf_enrichment_for_user",
|
||||
_fake_schedule,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-bg@example.com", password="api-password")
|
||||
|
||||
response = client.get("/api/v1/publications?mode=all")
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert len(data["publications"]) == 1
|
||||
assert int(data["publications"][0]["publication_id"]) == publication_id
|
||||
assert data["publications"][0]["pdf_url"] is None
|
||||
assert data["publications"][0]["pdf_status"] in {"untracked", "queued", "running", "failed"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publication_retry_pdf_queues_resolution_job(
|
||||
db_session: AsyncSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-retry@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "retryScholar01",
|
||||
"display_name": "Retry Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
publication_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 7)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 9):064x}",
|
||||
"title_raw": "Retry Target",
|
||||
"title_normalized": "retry target",
|
||||
},
|
||||
)
|
||||
publication_id = int(publication_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES (:scholar_profile_id, :publication_id, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def _fake_retry_scheduler(db_session, *, user_id: int, request_email: str | None, item: PublicationListItem):
|
||||
assert db_session is not None
|
||||
assert user_id > 0
|
||||
assert request_email == "api-pubs-retry@example.com"
|
||||
assert int(item.publication_id) == publication_id
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.publications.application.schedule_retry_pdf_enrichment_for_row",
|
||||
_fake_retry_scheduler,
|
||||
)
|
||||
|
||||
async def _fake_hydrate(db_session, *, items: list[PublicationListItem]):
|
||||
assert db_session is not None
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
return [
|
||||
PublicationListItem(
|
||||
publication_id=item.publication_id,
|
||||
scholar_profile_id=item.scholar_profile_id,
|
||||
scholar_label=item.scholar_label,
|
||||
title=item.title,
|
||||
year=item.year,
|
||||
citation_count=item.citation_count,
|
||||
venue_text=item.venue_text,
|
||||
pub_url=item.pub_url,
|
||||
pdf_url=item.pdf_url,
|
||||
is_read=item.is_read,
|
||||
first_seen_at=item.first_seen_at,
|
||||
is_new_in_latest_run=item.is_new_in_latest_run,
|
||||
pdf_status="queued",
|
||||
pdf_attempt_count=1,
|
||||
pdf_failure_reason="no_pdf_found",
|
||||
pdf_failure_detail="no_pdf_found",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.publications.application.hydrate_pdf_enrichment_state",
|
||||
_fake_hydrate,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-retry@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/publications/{publication_id}/retry-pdf",
|
||||
json={"scholar_profile_id": scholar_profile_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["queued"] is True
|
||||
assert payload["resolved_pdf"] is False
|
||||
assert payload["publication"]["publication_id"] == publication_id
|
||||
assert payload["publication"]["pdf_url"] is None
|
||||
assert payload["publication"]["pdf_status"] == "queued"
|
||||
assert payload["publication"]["pdf_attempt_count"] == 1
|
||||
assert payload["publication"]["pdf_failure_reason"] == "no_pdf_found"
|
||||
|
||||
stored = await db_session.execute(
|
||||
text("SELECT pdf_url FROM publications WHERE id = :publication_id"),
|
||||
{"publication_id": publication_id},
|
||||
)
|
||||
stored_pdf_url = stored.scalar_one()
|
||||
assert stored_pdf_url is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_publications_unread_and_latest_modes_can_diverge(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-pubs-mismatch@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "mismatchScholar01",
|
||||
"display_name": "Mismatch Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
|
||||
older_run_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
|
||||
VALUES (:user_id, 'manual', 'success', 1, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
older_run_id = int(older_run_result.scalar_one())
|
||||
|
||||
latest_run_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
|
||||
VALUES (:user_id, 'scheduled', 'success', 1, 0)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
latest_run_id = int(latest_run_result.scalar_one())
|
||||
assert latest_run_id > older_run_id
|
||||
|
||||
publication_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 12)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 99):064x}",
|
||||
"title_raw": "Unread But Not Latest",
|
||||
"title_normalized": "unread but not latest",
|
||||
},
|
||||
)
|
||||
publication_id = int(publication_result.scalar_one())
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (
|
||||
scholar_profile_id,
|
||||
publication_id,
|
||||
is_read,
|
||||
first_seen_run_id
|
||||
)
|
||||
VALUES (:scholar_profile_id, :publication_id, false, :first_seen_run_id)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
"first_seen_run_id": older_run_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-pubs-mismatch@example.com", password="api-password")
|
||||
|
||||
unread_response = client.get("/api/v1/publications?mode=unread")
|
||||
assert unread_response.status_code == 200
|
||||
unread_data = unread_response.json()["data"]
|
||||
assert unread_data["mode"] == "unread"
|
||||
assert unread_data["unread_count"] == 1
|
||||
assert unread_data["latest_count"] == 0
|
||||
assert unread_data["new_count"] == 0
|
||||
assert len(unread_data["publications"]) == 1
|
||||
assert int(unread_data["publications"][0]["publication_id"]) == publication_id
|
||||
assert unread_data["publications"][0]["is_new_in_latest_run"] is False
|
||||
|
||||
latest_response = client.get("/api/v1/publications?mode=latest")
|
||||
assert latest_response.status_code == 200
|
||||
latest_data = latest_response.json()["data"]
|
||||
assert latest_data["mode"] == "latest"
|
||||
assert latest_data["latest_count"] == 0
|
||||
assert len(latest_data["publications"]) == 0
|
||||
|
||||
alias_response = client.get("/api/v1/publications?mode=new")
|
||||
assert alias_response.status_code == 200
|
||||
alias_data = alias_response.json()["data"]
|
||||
assert alias_data["mode"] == "latest"
|
||||
assert alias_data["latest_count"] == latest_data["latest_count"]
|
||||
assert alias_data["publications"] == latest_data["publications"]
|
||||
499
tests/integration/test_api_runs.py
Normal file
499
tests/integration/test_api_runs.py
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.runtime_deps import get_scholar_source
|
||||
from app.main import app
|
||||
from app.services.scholar.source import FetchResult
|
||||
from app.settings import settings
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
assert_safety_state_contract,
|
||||
insert_user,
|
||||
login_user,
|
||||
regression_fixture,
|
||||
wait_for_run_complete,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_manual_run_skips_unchanged_initial_page_for_scholar(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-skip-unchanged@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
profile_html = """
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:image" content="https://images.example.com/skip.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="gsc_prf_in">Skip Candidate</div>
|
||||
<span id="gsc_a_nn">Articles 1-1</span>
|
||||
<table>
|
||||
<tbody id="gsc_a_b">
|
||||
<tr class="gsc_a_tr">
|
||||
<td class="gsc_a_t">
|
||||
<a class="gsc_a_at" href="/citations?view_op=view_citation&citation_for_view=abcDEF123456:xyz123">Stable Paper</a>
|
||||
<div class="gs_gray">A Author</div>
|
||||
<div class="gs_gray">Stable Venue</div>
|
||||
</td>
|
||||
<td class="gsc_a_c"><a class="gsc_a_ac">5</a></td>
|
||||
<td class="gsc_a_y"><span class="gsc_a_h">2024</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
class StubScholarSource:
|
||||
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||
assert scholar_id == "abcDEF123456"
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
body=profile_html,
|
||||
error=None,
|
||||
)
|
||||
|
||||
async def fetch_profile_page_html(
|
||||
self,
|
||||
scholar_id: str,
|
||||
*,
|
||||
cstart: int,
|
||||
pagesize: int,
|
||||
) -> FetchResult:
|
||||
assert scholar_id == "abcDEF123456"
|
||||
assert cstart == 0
|
||||
assert pagesize == settings.ingestion_page_size
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
body=profile_html,
|
||||
error=None,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
login_user(client, email="api-skip-unchanged@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/scholars",
|
||||
json={"scholar_id": "abcDEF123456"},
|
||||
headers=headers,
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
first_run_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "skip-unchanged-run-001"},
|
||||
)
|
||||
assert first_run_response.status_code == 200
|
||||
first_run_id = int(first_run_response.json()["data"]["run_id"])
|
||||
|
||||
first_detail_data = await wait_for_run_complete(client, first_run_id)
|
||||
first_results = first_detail_data["scholar_results"]
|
||||
assert len(first_results) == 1
|
||||
assert first_results[0]["state_reason"] != "no_change_initial_page_signature"
|
||||
|
||||
second_run_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "skip-unchanged-run-002"},
|
||||
)
|
||||
assert second_run_response.status_code == 200
|
||||
second_run_id = int(second_run_response.json()["data"]["run_id"])
|
||||
|
||||
second_detail_data = await wait_for_run_complete(client, second_run_id)
|
||||
second_results = second_detail_data["scholar_results"]
|
||||
assert len(second_results) == 1
|
||||
assert second_results[0]["state_reason"] == "no_change_initial_page_signature"
|
||||
assert second_results[0]["publication_count"] == 0
|
||||
assert second_results[0]["outcome"] == "success"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_scholar_source, None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-runs@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-runs@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
run_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "manual-run-0001"},
|
||||
)
|
||||
assert run_response.status_code == 200
|
||||
run_payload = run_response.json()["data"]
|
||||
assert "run_id" in run_payload
|
||||
assert run_payload["status"] in {"running", "resolving", "success", "partial_failure", "failed"}
|
||||
assert run_payload["reused_existing_run"] is False
|
||||
assert run_payload["idempotency_key"] == "manual-run-0001"
|
||||
assert_safety_state_contract(run_payload["safety_state"])
|
||||
run_id = int(run_payload["run_id"])
|
||||
|
||||
stored_key = await db_session.execute(
|
||||
text("SELECT idempotency_key FROM crawl_runs WHERE id = :run_id"),
|
||||
{"run_id": run_id},
|
||||
)
|
||||
assert stored_key.scalar_one() == "manual-run-0001"
|
||||
|
||||
replay_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "manual-run-0001"},
|
||||
)
|
||||
assert replay_response.status_code in {200, 409}
|
||||
if replay_response.status_code == 200:
|
||||
replay_payload = replay_response.json()["data"]
|
||||
assert replay_payload["run_id"] == run_payload["run_id"]
|
||||
assert replay_payload["reused_existing_run"] is True
|
||||
assert_safety_state_contract(replay_payload["safety_state"])
|
||||
else:
|
||||
replay_error = replay_response.json()["error"]
|
||||
assert replay_error["code"] == "run_in_progress"
|
||||
|
||||
runs_response = client.get("/api/v1/runs")
|
||||
assert runs_response.status_code == 200
|
||||
assert len(runs_response.json()["data"]["runs"]) >= 1
|
||||
assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
|
||||
|
||||
run_detail_response = client.get(f"/api/v1/runs/{run_id}")
|
||||
assert run_detail_response.status_code == 200
|
||||
detail_payload = run_detail_response.json()["data"]
|
||||
assert "summary" in detail_payload
|
||||
assert isinstance(detail_payload["scholar_results"], list)
|
||||
assert_safety_state_contract(detail_payload["safety_state"])
|
||||
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "abcDEF123456",
|
||||
"display_name": "Queue Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
queue_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ingestion_queue_items (
|
||||
user_id,
|
||||
scholar_profile_id,
|
||||
resume_cstart,
|
||||
reason,
|
||||
status,
|
||||
attempt_count,
|
||||
next_attempt_dt,
|
||||
dropped_reason,
|
||||
dropped_at
|
||||
)
|
||||
VALUES (
|
||||
:user_id,
|
||||
:scholar_profile_id,
|
||||
7,
|
||||
'dropped',
|
||||
'dropped',
|
||||
2,
|
||||
NOW(),
|
||||
'manual_drop',
|
||||
NOW()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "scholar_profile_id": scholar_profile_id},
|
||||
)
|
||||
queue_item_id = int(queue_result.scalar_one())
|
||||
await db_session.commit()
|
||||
|
||||
queue_list_response = client.get("/api/v1/runs/queue/items")
|
||||
assert queue_list_response.status_code == 200
|
||||
assert any(int(item["id"]) == queue_item_id for item in queue_list_response.json()["data"]["queue_items"])
|
||||
|
||||
retry_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/retry", headers=headers)
|
||||
assert retry_response.status_code == 200
|
||||
assert retry_response.json()["data"]["status"] == "queued"
|
||||
|
||||
retry_again_response = client.post(
|
||||
f"/api/v1/runs/queue/{queue_item_id}/retry",
|
||||
headers=headers,
|
||||
)
|
||||
assert retry_again_response.status_code == 409
|
||||
assert retry_again_response.json()["error"]["code"] == "queue_item_already_queued"
|
||||
|
||||
drop_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/drop", headers=headers)
|
||||
assert drop_response.status_code == 200
|
||||
assert drop_response.json()["data"]["status"] == "dropped"
|
||||
|
||||
clear_response = client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/runs/queue/{queue_item_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert clear_response.status_code == 200
|
||||
assert clear_response.json()["data"]["status"] == "cleared"
|
||||
assert clear_response.json()["data"]["message"] == "Queue item cleared."
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_manual_run_can_be_disabled_by_policy(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-runs-policy@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-runs-policy@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
previous_manual_allowed = settings.ingestion_manual_run_allowed
|
||||
object.__setattr__(settings, "ingestion_manual_run_allowed", False)
|
||||
try:
|
||||
response = client.post("/api/v1/runs/manual", headers=headers)
|
||||
assert response.status_code == 403
|
||||
payload = response.json()
|
||||
assert payload["error"]["code"] == "manual_runs_disabled"
|
||||
assert payload["error"]["details"]["policy"]["manual_run_allowed"] is False
|
||||
assert payload["error"]["details"]["safety_state"]["cooldown_active"] is False
|
||||
finally:
|
||||
object.__setattr__(settings, "ingestion_manual_run_allowed", previous_manual_allowed)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_manual_run_enforces_scrape_safety_cooldown(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-runs-safety@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "A1B2C3D4E5F6",
|
||||
"display_name": "Safety Probe",
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
blocked_fixture = regression_fixture("profile_AAAAAAAAAAAA.html")
|
||||
|
||||
class BlockedScholarSource:
|
||||
async def fetch_profile_page_html(
|
||||
self,
|
||||
scholar_id: str,
|
||||
*,
|
||||
cstart: int,
|
||||
pagesize: int,
|
||||
) -> FetchResult:
|
||||
_ = (scholar_id, cstart, pagesize)
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=A1B2C3D4E5F6",
|
||||
status_code=200,
|
||||
final_url=(
|
||||
"https://accounts.google.com/v3/signin/identifier"
|
||||
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
|
||||
),
|
||||
body=blocked_fixture,
|
||||
error=None,
|
||||
)
|
||||
|
||||
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||
_ = scholar_id
|
||||
return await self.fetch_profile_page_html(
|
||||
"A1B2C3D4E5F6",
|
||||
cstart=0,
|
||||
pagesize=settings.ingestion_page_size,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_scholar_source] = lambda: BlockedScholarSource()
|
||||
|
||||
previous_blocked_cooldown_seconds = settings.ingestion_safety_cooldown_blocked_seconds
|
||||
object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", 600)
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
login_user(client, email="api-runs-safety@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
preflight_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "safety-cooldown-run-1"},
|
||||
)
|
||||
assert preflight_response.status_code == 429
|
||||
preflight_payload = preflight_response.json()
|
||||
assert preflight_payload["error"]["code"] == "scrape_cooldown_active"
|
||||
preflight_state = preflight_payload["error"]["details"]["safety_state"]
|
||||
assert_safety_state_contract(preflight_state)
|
||||
assert preflight_state["cooldown_active"] is True
|
||||
|
||||
settings_response = client.get("/api/v1/settings")
|
||||
assert settings_response.status_code == 200
|
||||
safety_state = settings_response.json()["data"]["safety_state"]
|
||||
assert_safety_state_contract(safety_state)
|
||||
assert safety_state["cooldown_active"] is True
|
||||
assert safety_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
|
||||
assert int(safety_state["cooldown_remaining_seconds"]) > 0
|
||||
assert int(safety_state["counters"]["cooldown_entry_count"]) >= 1
|
||||
|
||||
blocked_start_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "safety-cooldown-run-2"},
|
||||
)
|
||||
assert blocked_start_response.status_code == 429
|
||||
blocked_payload = blocked_start_response.json()
|
||||
assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
|
||||
blocked_state = blocked_payload["error"]["details"]["safety_state"]
|
||||
assert_safety_state_contract(blocked_state)
|
||||
assert blocked_state["cooldown_active"] is True
|
||||
assert blocked_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
|
||||
assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
|
||||
|
||||
runs_response = client.get("/api/v1/runs")
|
||||
assert runs_response.status_code == 200
|
||||
assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
|
||||
assert runs_response.json()["data"]["safety_state"]["cooldown_active"] is True
|
||||
finally:
|
||||
object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", previous_blocked_cooldown_seconds)
|
||||
app.dependency_overrides.pop(get_scholar_source, None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_manual_run_enforces_network_failure_safety_cooldown(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-runs-safety-network@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "NNN111NNN111",
|
||||
"display_name": "Network Safety Probe",
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
class NetworkFailureScholarSource:
|
||||
async def fetch_profile_page_html(
|
||||
self,
|
||||
scholar_id: str,
|
||||
*,
|
||||
cstart: int,
|
||||
pagesize: int,
|
||||
) -> FetchResult:
|
||||
_ = (scholar_id, cstart, pagesize)
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=NNN111NNN111",
|
||||
status_code=None,
|
||||
final_url=None,
|
||||
body="",
|
||||
error="timed out",
|
||||
)
|
||||
|
||||
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||
_ = scholar_id
|
||||
return await self.fetch_profile_page_html(
|
||||
"NNN111NNN111",
|
||||
cstart=0,
|
||||
pagesize=settings.ingestion_page_size,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_scholar_source] = lambda: NetworkFailureScholarSource()
|
||||
|
||||
previous_network_threshold = settings.ingestion_alert_network_failure_threshold
|
||||
previous_network_cooldown_seconds = settings.ingestion_safety_cooldown_network_seconds
|
||||
previous_network_retries = settings.ingestion_network_error_retries
|
||||
previous_retry_backoff = settings.ingestion_retry_backoff_seconds
|
||||
object.__setattr__(settings, "ingestion_alert_network_failure_threshold", 1)
|
||||
object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", 600)
|
||||
object.__setattr__(settings, "ingestion_network_error_retries", 0)
|
||||
object.__setattr__(settings, "ingestion_retry_backoff_seconds", 0.0)
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
login_user(client, email="api-runs-safety-network@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
first_run_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-1"},
|
||||
)
|
||||
assert first_run_response.status_code == 200
|
||||
first_run_id = int(first_run_response.json()["data"]["run_id"])
|
||||
await wait_for_run_complete(client, first_run_id)
|
||||
|
||||
settings_response = client.get("/api/v1/settings")
|
||||
assert settings_response.status_code == 200
|
||||
safety_state = settings_response.json()["data"]["safety_state"]
|
||||
assert_safety_state_contract(safety_state)
|
||||
assert safety_state["cooldown_active"] is True
|
||||
assert safety_state["cooldown_reason"] == "network_failure_threshold_exceeded"
|
||||
assert int(safety_state["cooldown_remaining_seconds"]) > 0
|
||||
assert int(safety_state["counters"]["last_network_failure_count"]) >= 1
|
||||
|
||||
blocked_start_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-2"},
|
||||
)
|
||||
assert blocked_start_response.status_code == 429
|
||||
blocked_payload = blocked_start_response.json()
|
||||
assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
|
||||
blocked_state = blocked_payload["error"]["details"]["safety_state"]
|
||||
assert_safety_state_contract(blocked_state)
|
||||
assert blocked_state["cooldown_active"] is True
|
||||
assert blocked_state["cooldown_reason"] == "network_failure_threshold_exceeded"
|
||||
assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
|
||||
finally:
|
||||
object.__setattr__(settings, "ingestion_alert_network_failure_threshold", previous_network_threshold)
|
||||
object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", previous_network_cooldown_seconds)
|
||||
object.__setattr__(settings, "ingestion_network_error_retries", previous_network_retries)
|
||||
object.__setattr__(settings, "ingestion_retry_backoff_seconds", previous_retry_backoff)
|
||||
app.dependency_overrides.pop(get_scholar_source, None)
|
||||
370
tests/integration/test_api_scholars.py
Normal file
370
tests/integration/test_api_scholars.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.runtime_deps import get_scholar_source
|
||||
from app.main import app
|
||||
from app.services.scholar.source import FetchResult
|
||||
from app.settings import settings
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-scholars@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-scholars@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
missing_csrf = client.post(
|
||||
"/api/v1/scholars",
|
||||
json={"scholar_id": "abcDEF123456"},
|
||||
)
|
||||
assert missing_csrf.status_code == 403
|
||||
assert missing_csrf.json()["error"]["code"] == "csrf_invalid"
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/scholars",
|
||||
json={"scholar_id": "abcDEF123456"},
|
||||
headers=headers,
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()["data"]
|
||||
assert created["scholar_id"] == "abcDEF123456"
|
||||
scholar_profile_id = int(created["id"])
|
||||
|
||||
list_response = client.get("/api/v1/scholars")
|
||||
assert list_response.status_code == 200
|
||||
scholars = list_response.json()["data"]["scholars"]
|
||||
assert any(int(item["id"]) == scholar_profile_id for item in scholars)
|
||||
|
||||
toggle_response = client.patch(
|
||||
f"/api/v1/scholars/{scholar_profile_id}/toggle",
|
||||
headers=headers,
|
||||
)
|
||||
assert toggle_response.status_code == 200
|
||||
assert toggle_response.json()["data"]["is_enabled"] is False
|
||||
|
||||
delete_response = client.delete(
|
||||
f"/api/v1/scholars/{scholar_profile_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["data"]["message"] == "Scholar deleted."
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_scholars_search_and_profile_image_management(
|
||||
db_session: AsyncSession,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-scholar-images@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
class StubScholarSource:
|
||||
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||
assert scholar_id == "abcDEF123456"
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
body=(
|
||||
"<html><head>"
|
||||
'<meta property="og:image" content="https://images.example.com/ada.png" />'
|
||||
"</head><body>"
|
||||
'<div id="gsc_prf_in">Ada Lovelace</div>'
|
||||
"</body></html>"
|
||||
),
|
||||
error=None,
|
||||
)
|
||||
|
||||
async def fetch_author_search_html(self, query: str, *, start: int) -> FetchResult:
|
||||
assert query == "Ada Lovelace"
|
||||
assert start == 0
|
||||
return FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
|
||||
body=(
|
||||
'<div class="gsc_1usr">'
|
||||
'<img src="/citations/images/avatar_scholar_256.png" />'
|
||||
'<a class="gs_ai_name" href="/citations?hl=en&user=abcDEF123456">Ada Lovelace</a>'
|
||||
'<div class="gs_ai_aff">Analytical Engine</div>'
|
||||
'<div class="gs_ai_eml">Verified email at computing.example</div>'
|
||||
'<div class="gs_ai_cby">Cited by 42</div>'
|
||||
'<a class="gs_ai_one_int">Mathematics</a>'
|
||||
"</div>"
|
||||
),
|
||||
error=None,
|
||||
)
|
||||
|
||||
previous_upload_dir = settings.scholar_image_upload_dir
|
||||
previous_upload_max_bytes = settings.scholar_image_upload_max_bytes
|
||||
previous_queue_enabled = settings.ingestion_continuation_queue_enabled
|
||||
app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
|
||||
object.__setattr__(settings, "scholar_image_upload_dir", str(tmp_path / "scholar_images"))
|
||||
object.__setattr__(settings, "scholar_image_upload_max_bytes", 1_000_000)
|
||||
object.__setattr__(settings, "ingestion_continuation_queue_enabled", False)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-scholar-images@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
search_response = client.get("/api/v1/scholars/search", params={"query": "Ada Lovelace", "limit": 5})
|
||||
assert search_response.status_code == 200
|
||||
search_payload = search_response.json()["data"]
|
||||
assert search_payload["state"] == "ok"
|
||||
assert len(search_payload["candidates"]) == 1
|
||||
candidate = search_payload["candidates"][0]
|
||||
assert candidate["scholar_id"] == "abcDEF123456"
|
||||
assert candidate["profile_image_url"] == "https://scholar.google.com/citations/images/avatar_scholar_256.png"
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/scholars",
|
||||
json={
|
||||
"scholar_id": candidate["scholar_id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()["data"]
|
||||
scholar_profile_id = int(created["id"])
|
||||
assert created["profile_image_source"] == "scraped"
|
||||
assert created["profile_image_url"] == "https://images.example.com/ada.png"
|
||||
|
||||
set_url_response = client.put(
|
||||
f"/api/v1/scholars/{scholar_profile_id}/image/url",
|
||||
json={"image_url": "https://cdn.example.com/custom-avatar.png"},
|
||||
headers=headers,
|
||||
)
|
||||
assert set_url_response.status_code == 200
|
||||
set_url_data = set_url_response.json()["data"]
|
||||
assert set_url_data["profile_image_source"] == "override"
|
||||
assert set_url_data["profile_image_url"] == "https://cdn.example.com/custom-avatar.png"
|
||||
|
||||
uploaded_bytes = b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01"
|
||||
upload_response = client.post(
|
||||
f"/api/v1/scholars/{scholar_profile_id}/image/upload",
|
||||
files={"image": ("avatar.png", uploaded_bytes, "image/png")},
|
||||
headers=headers,
|
||||
)
|
||||
assert upload_response.status_code == 200
|
||||
upload_data = upload_response.json()["data"]
|
||||
assert upload_data["profile_image_source"] == "upload"
|
||||
assert upload_data["profile_image_url"] == f"/scholar-images/{scholar_profile_id}/upload"
|
||||
|
||||
uploaded_image_response = client.get(f"/scholar-images/{scholar_profile_id}/upload")
|
||||
assert uploaded_image_response.status_code == 200
|
||||
assert uploaded_image_response.headers["content-type"].startswith("image/png")
|
||||
assert uploaded_image_response.content == uploaded_bytes
|
||||
|
||||
clear_response = client.delete(
|
||||
f"/api/v1/scholars/{scholar_profile_id}/image",
|
||||
headers=headers,
|
||||
)
|
||||
assert clear_response.status_code == 200
|
||||
clear_data = clear_response.json()["data"]
|
||||
assert clear_data["profile_image_source"] == "scraped"
|
||||
assert clear_data["profile_image_url"] == "https://images.example.com/ada.png"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_scholar_source, None)
|
||||
object.__setattr__(settings, "scholar_image_upload_dir", previous_upload_dir)
|
||||
object.__setattr__(
|
||||
settings,
|
||||
"scholar_image_upload_max_bytes",
|
||||
previous_upload_max_bytes,
|
||||
)
|
||||
object.__setattr__(settings, "ingestion_continuation_queue_enabled", previous_queue_enabled)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_scholar_import_export_round_trip(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-import-export@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "abcDEF123456",
|
||||
"display_name": "Existing Scholar",
|
||||
},
|
||||
)
|
||||
scholar_profile_id = int(scholar_result.scalar_one())
|
||||
publication_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||
VALUES (:fingerprint, :title_raw, :title_normalized, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": f"{(user_id + 500):064x}",
|
||||
"title_raw": "Existing Publication",
|
||||
"title_normalized": "existingpublication",
|
||||
},
|
||||
)
|
||||
publication_id = int(publication_result.scalar_one())
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||
VALUES (:scholar_profile_id, :publication_id, false)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-import-export@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
export_response = client.get("/api/v1/scholars/export")
|
||||
assert export_response.status_code == 200
|
||||
export_payload = export_response.json()["data"]
|
||||
assert export_payload["schema_version"] == 1
|
||||
assert len(export_payload["scholars"]) == 1
|
||||
assert len(export_payload["publications"]) == 1
|
||||
|
||||
import_response = client.post(
|
||||
"/api/v1/scholars/import",
|
||||
json={
|
||||
"schema_version": 1,
|
||||
"scholars": [
|
||||
{
|
||||
"scholar_id": "abcDEF123456",
|
||||
"display_name": "Updated Scholar",
|
||||
"is_enabled": False,
|
||||
"profile_image_override_url": "https://cdn.example.com/avatar.png",
|
||||
},
|
||||
{
|
||||
"scholar_id": "zzzYYY111222",
|
||||
"display_name": "Imported Scholar",
|
||||
"is_enabled": True,
|
||||
"profile_image_override_url": None,
|
||||
},
|
||||
],
|
||||
"publications": [
|
||||
{
|
||||
"scholar_id": "abcDEF123456",
|
||||
"title": "Existing Publication",
|
||||
"year": 2024,
|
||||
"citation_count": 8,
|
||||
"author_text": "A. Author",
|
||||
"venue_text": "Test Venue",
|
||||
"pub_url": "https://example.org/existing",
|
||||
"pdf_url": "https://example.org/existing.pdf",
|
||||
"is_read": True,
|
||||
},
|
||||
{
|
||||
"scholar_id": "zzzYYY111222",
|
||||
"title": "Imported Publication",
|
||||
"year": 2025,
|
||||
"citation_count": 2,
|
||||
"author_text": "B. Author",
|
||||
"venue_text": "Another Venue",
|
||||
"pub_url": "https://example.org/imported",
|
||||
"pdf_url": "https://example.org/imported.pdf",
|
||||
"is_read": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert import_response.status_code == 200
|
||||
import_data = import_response.json()["data"]
|
||||
assert int(import_data["scholars_created"]) == 1
|
||||
assert int(import_data["scholars_updated"]) >= 1
|
||||
assert int(import_data["publications_created"]) == 1
|
||||
assert int(import_data["links_created"]) == 1
|
||||
|
||||
updated_scholar_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT display_name, is_enabled, profile_image_override_url
|
||||
FROM scholar_profiles
|
||||
WHERE user_id = :user_id AND scholar_id = :scholar_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "abcDEF123456",
|
||||
},
|
||||
)
|
||||
updated_scholar = updated_scholar_result.one()
|
||||
assert updated_scholar[0] == "Updated Scholar"
|
||||
assert updated_scholar[1] is False
|
||||
assert updated_scholar[2] == "https://cdn.example.com/avatar.png"
|
||||
|
||||
imported_pub_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT p.title_raw, p.pdf_url
|
||||
FROM publications p
|
||||
WHERE p.title_raw = :title
|
||||
"""
|
||||
),
|
||||
{"title": "Imported Publication"},
|
||||
)
|
||||
imported_pub = imported_pub_result.one()
|
||||
assert imported_pub[0] == "Imported Publication"
|
||||
assert imported_pub[1] == "https://example.org/imported.pdf"
|
||||
|
||||
updated_link_result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT sp.is_read
|
||||
FROM scholar_publications sp
|
||||
JOIN scholar_profiles s ON s.id = sp.scholar_profile_id
|
||||
JOIN publications p ON p.id = sp.publication_id
|
||||
WHERE s.user_id = :user_id
|
||||
AND s.scholar_id = :scholar_id
|
||||
AND p.title_raw = :title
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_id": "abcDEF123456",
|
||||
"title": "Existing Publication",
|
||||
},
|
||||
)
|
||||
assert bool(updated_link_result.scalar_one()) is True
|
||||
183
tests/integration/test_api_settings.py
Normal file
183
tests/integration/test_api_settings.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from app.services.settings import application as user_settings_service
|
||||
from app.settings import settings
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
assert_safety_state_contract,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-settings@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-settings@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
get_response = client.get("/api/v1/settings")
|
||||
assert get_response.status_code == 200
|
||||
settings_payload = get_response.json()["data"]
|
||||
assert "request_delay_seconds" in settings_payload
|
||||
assert "nav_visible_pages" in settings_payload
|
||||
assert settings_payload["policy"]["min_run_interval_minutes"] == user_settings_service.resolve_run_interval_minimum(
|
||||
settings.ingestion_min_run_interval_minutes
|
||||
)
|
||||
assert settings_payload["policy"][
|
||||
"min_request_delay_seconds"
|
||||
] == user_settings_service.resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds)
|
||||
assert settings_payload["policy"]["automation_allowed"] is settings.ingestion_automation_allowed
|
||||
assert settings_payload["policy"]["manual_run_allowed"] is settings.ingestion_manual_run_allowed
|
||||
assert settings_payload["policy"]["blocked_failure_threshold"] == max(
|
||||
1,
|
||||
int(settings.ingestion_alert_blocked_failure_threshold),
|
||||
)
|
||||
assert settings_payload["policy"]["network_failure_threshold"] == max(
|
||||
1,
|
||||
int(settings.ingestion_alert_network_failure_threshold),
|
||||
)
|
||||
assert settings_payload["policy"]["cooldown_blocked_seconds"] == max(
|
||||
60,
|
||||
int(settings.ingestion_safety_cooldown_blocked_seconds),
|
||||
)
|
||||
assert settings_payload["policy"]["cooldown_network_seconds"] == max(
|
||||
60,
|
||||
int(settings.ingestion_safety_cooldown_network_seconds),
|
||||
)
|
||||
assert_safety_state_contract(settings_payload["safety_state"])
|
||||
assert settings_payload["safety_state"]["cooldown_active"] is False
|
||||
|
||||
policy = settings_payload["policy"]
|
||||
run_interval_minutes = max(45, int(policy["min_run_interval_minutes"]))
|
||||
request_delay_seconds = max(6, int(policy["min_request_delay_seconds"]))
|
||||
|
||||
update_response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"auto_run_enabled": True,
|
||||
"run_interval_minutes": run_interval_minutes,
|
||||
"request_delay_seconds": request_delay_seconds,
|
||||
"nav_visible_pages": ["dashboard", "scholars", "publications", "settings", "runs"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()["data"]
|
||||
assert updated["auto_run_enabled"] is True
|
||||
assert updated["run_interval_minutes"] == run_interval_minutes
|
||||
assert updated["request_delay_seconds"] == request_delay_seconds
|
||||
assert updated["nav_visible_pages"] == [
|
||||
"dashboard",
|
||||
"scholars",
|
||||
"publications",
|
||||
"settings",
|
||||
"runs",
|
||||
]
|
||||
assert "policy" in updated
|
||||
assert_safety_state_contract(updated["safety_state"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_settings_enforce_env_minimums(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-settings-policy@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-settings-policy@example.com", password="api-password")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
previous_min_interval = settings.ingestion_min_run_interval_minutes
|
||||
previous_min_delay = settings.ingestion_min_request_delay_seconds
|
||||
object.__setattr__(settings, "ingestion_min_run_interval_minutes", 30)
|
||||
object.__setattr__(settings, "ingestion_min_request_delay_seconds", 8)
|
||||
try:
|
||||
interval_response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"auto_run_enabled": True,
|
||||
"run_interval_minutes": 29,
|
||||
"request_delay_seconds": 9,
|
||||
"nav_visible_pages": ["dashboard", "scholars", "settings"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert interval_response.status_code == 400
|
||||
assert interval_response.json()["error"]["code"] == "invalid_settings"
|
||||
assert interval_response.json()["error"]["message"] == "Check interval must be at least 30 minutes."
|
||||
|
||||
delay_response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"auto_run_enabled": True,
|
||||
"run_interval_minutes": 30,
|
||||
"request_delay_seconds": 7,
|
||||
"nav_visible_pages": ["dashboard", "scholars", "settings"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert delay_response.status_code == 400
|
||||
assert delay_response.json()["error"]["code"] == "invalid_settings"
|
||||
assert delay_response.json()["error"]["message"] == "Request delay must be at least 8 seconds."
|
||||
finally:
|
||||
object.__setattr__(settings, "ingestion_min_run_interval_minutes", previous_min_interval)
|
||||
object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous_min_delay)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_settings_clears_expired_scrape_safety_cooldown(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="api-settings-safety-expired@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
user_settings = await user_settings_service.get_or_create_settings(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
)
|
||||
user_settings.scrape_safety_state = {"blocked_start_count": 2}
|
||||
user_settings.scrape_cooldown_reason = "blocked_failure_threshold_exceeded"
|
||||
user_settings.scrape_cooldown_until = datetime.now(UTC) - timedelta(seconds=15)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="api-settings-safety-expired@example.com", password="api-password")
|
||||
|
||||
settings_response = client.get("/api/v1/settings")
|
||||
assert settings_response.status_code == 200
|
||||
settings_safety_state = settings_response.json()["data"]["safety_state"]
|
||||
assert_safety_state_contract(settings_safety_state)
|
||||
assert settings_safety_state["cooldown_active"] is False
|
||||
assert settings_safety_state["cooldown_reason"] is None
|
||||
assert int(settings_safety_state["counters"]["blocked_start_count"]) == 2
|
||||
|
||||
runs_response = client.get("/api/v1/runs")
|
||||
assert runs_response.status_code == 200
|
||||
runs_safety_state = runs_response.json()["data"]["safety_state"]
|
||||
assert_safety_state_contract(runs_safety_state)
|
||||
assert runs_safety_state["cooldown_active"] is False
|
||||
assert runs_safety_state["cooldown_reason"] is None
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -95,7 +95,7 @@ async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
|
|||
"retry": "RSTUVWX12345",
|
||||
}
|
||||
|
||||
for label in ("ok", "blocked", "retry"):
|
||||
for label in ("ok", "retry", "blocked"):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,9 +13,29 @@ from app.api.runtime_deps import get_ingestion_service
|
|||
from app.db.models import CrawlRun, Publication, RunStatus, RunTriggerType
|
||||
from app.main import app
|
||||
from app.services.ingestion.application import ScholarIngestionService
|
||||
from app.services.ingestion.run_enrichment import background_enrich
|
||||
from app.services.scholar.source import FetchResult
|
||||
from tests.integration.helpers import insert_user, login_user
|
||||
|
||||
|
||||
class _PassthroughSource:
|
||||
"""Minimal source that passes preflight without making real requests."""
|
||||
|
||||
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||
return FetchResult(
|
||||
requested_url=f"https://scholar.google.com/citations?user={scholar_id}",
|
||||
status_code=200,
|
||||
final_url=f"https://scholar.google.com/citations?user={scholar_id}",
|
||||
body="<html></html>",
|
||||
error=None,
|
||||
)
|
||||
|
||||
async def fetch_profile_page_html(
|
||||
self, scholar_id: str, *, cstart: int, pagesize: int
|
||||
) -> FetchResult:
|
||||
return await self.fetch_profile_html(scholar_id)
|
||||
|
||||
|
||||
def _csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
response = client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 200
|
||||
|
|
@ -81,7 +101,7 @@ async def test_api_manual_run_conflicts_when_an_active_run_exists(
|
|||
)
|
||||
await db_session.commit()
|
||||
|
||||
service = ScholarIngestionService(source=object())
|
||||
service = ScholarIngestionService(source=_PassthroughSource())
|
||||
|
||||
async def _stall_execute_run(**_kwargs: Any) -> None:
|
||||
await asyncio.sleep(0.4)
|
||||
|
|
@ -218,9 +238,12 @@ async def test_background_enrichment_preserves_canceled_status(
|
|||
_OpenAlexClientStub,
|
||||
)
|
||||
|
||||
service = ScholarIngestionService(source=object())
|
||||
await service._background_enrich(
|
||||
from app.services.ingestion.enrichment import EnrichmentRunner
|
||||
|
||||
enrichment_runner = EnrichmentRunner()
|
||||
await background_enrich(
|
||||
session_factory,
|
||||
enrichment_runner,
|
||||
run_id=int(run.id),
|
||||
intended_final_status=RunStatus.SUCCESS,
|
||||
)
|
||||
|
|
|
|||
40
tests/unit/test_logging_policy.py
Normal file
40
tests/unit/test_logging_policy.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
RAW_LOG_METHODS = {
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"exception",
|
||||
"critical",
|
||||
}
|
||||
APP_DIR = Path(__file__).resolve().parents[2] / "app"
|
||||
|
||||
|
||||
def _raw_logger_calls(path: Path) -> list[tuple[int, str]]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
violations: list[tuple[int, str]] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute):
|
||||
continue
|
||||
if not isinstance(func.value, ast.Name) or func.value.id != "logger":
|
||||
continue
|
||||
if func.attr not in RAW_LOG_METHODS:
|
||||
continue
|
||||
violations.append((int(node.lineno), func.attr))
|
||||
return violations
|
||||
|
||||
|
||||
def test_app_runtime_uses_structured_log_only() -> None:
|
||||
violations: list[str] = []
|
||||
for path in sorted(APP_DIR.rglob("*.py")):
|
||||
for line_number, method in _raw_logger_calls(path):
|
||||
relative_path = path.relative_to(APP_DIR.parent)
|
||||
violations.append(f"{relative_path}:{line_number} logger.{method}()")
|
||||
assert not violations, "raw logger calls found:\n" + "\n".join(violations)
|
||||
121
tests/unit/test_portability_import.py
Normal file
121
tests/unit/test_portability_import.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services.portability.publication_import import (
|
||||
_build_imported_publication_input,
|
||||
_initialize_import_counters,
|
||||
_update_link_counters,
|
||||
)
|
||||
|
||||
|
||||
def _mock_profile(scholar_id: str = "ABC123DEF456") -> MagicMock:
|
||||
profile = MagicMock()
|
||||
profile.id = 1
|
||||
profile.scholar_id = scholar_id
|
||||
return profile
|
||||
|
||||
|
||||
def _scholar_map(scholar_id: str = "ABC123DEF456") -> dict[str, MagicMock]:
|
||||
return {scholar_id: _mock_profile(scholar_id)}
|
||||
|
||||
|
||||
class TestBuildImportedPublicationInput:
|
||||
def test_returns_parsed_input_for_valid_item(self) -> None:
|
||||
item = {
|
||||
"scholar_id": "ABC123DEF456",
|
||||
"title": "Deep Learning",
|
||||
"year": 2024,
|
||||
"author_text": "Smith, J",
|
||||
"venue_text": "ICML",
|
||||
"citation_count": 10,
|
||||
}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.title == "Deep Learning"
|
||||
assert result.year == 2024
|
||||
assert result.citation_count == 10
|
||||
assert result.author_text == "Smith, J"
|
||||
|
||||
def test_returns_none_when_title_missing(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_scholar_id_missing(self) -> None:
|
||||
item = {"title": "Test Title"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_scholar_not_in_map(self) -> None:
|
||||
item = {"scholar_id": "UNKNOWN12345", "title": "Test Title"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is None
|
||||
|
||||
def test_defaults_is_read_to_false(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.is_read is False
|
||||
|
||||
def test_respects_is_read_true(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "is_read": True}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.is_read is True
|
||||
|
||||
def test_normalizes_year(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "year": "invalid"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.year is None
|
||||
|
||||
def test_normalizes_citation_count(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "citation_count": "not_a_number"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.citation_count == 0
|
||||
|
||||
def test_computes_fingerprint_when_not_provided(self) -> None:
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title"}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert len(result.fingerprint) == 64
|
||||
|
||||
def test_uses_provided_valid_fingerprint(self) -> None:
|
||||
valid_sha = "b" * 64
|
||||
item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "fingerprint_sha256": valid_sha}
|
||||
result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
|
||||
assert result is not None
|
||||
assert result.fingerprint == valid_sha
|
||||
|
||||
|
||||
class TestInitializeImportCounters:
|
||||
def test_adds_publication_and_link_keys(self) -> None:
|
||||
counters: dict[str, int] = {"existing_key": 5}
|
||||
_initialize_import_counters(counters)
|
||||
assert counters["publications_created"] == 0
|
||||
assert counters["publications_updated"] == 0
|
||||
assert counters["links_created"] == 0
|
||||
assert counters["links_updated"] == 0
|
||||
assert counters["existing_key"] == 5
|
||||
|
||||
|
||||
class TestUpdateLinkCounters:
|
||||
def test_increments_created(self) -> None:
|
||||
counters = {"links_created": 0, "links_updated": 0}
|
||||
_update_link_counters(counters=counters, link_created=True, link_updated=False)
|
||||
assert counters["links_created"] == 1
|
||||
assert counters["links_updated"] == 0
|
||||
|
||||
def test_increments_updated(self) -> None:
|
||||
counters = {"links_created": 0, "links_updated": 0}
|
||||
_update_link_counters(counters=counters, link_created=False, link_updated=True)
|
||||
assert counters["links_created"] == 0
|
||||
assert counters["links_updated"] == 1
|
||||
|
||||
def test_no_change_when_both_false(self) -> None:
|
||||
counters = {"links_created": 0, "links_updated": 0}
|
||||
_update_link_counters(counters=counters, link_created=False, link_updated=False)
|
||||
assert counters["links_created"] == 0
|
||||
assert counters["links_updated"] == 0
|
||||
193
tests/unit/test_portability_normalize.py
Normal file
193
tests/unit/test_portability_normalize.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.portability.constants import MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS
|
||||
from app.services.portability.normalize import (
|
||||
_build_fingerprint,
|
||||
_first_author_last_name,
|
||||
_first_venue_word,
|
||||
_normalize_citation_count,
|
||||
_normalize_optional_text,
|
||||
_normalize_optional_year,
|
||||
_resolve_fingerprint,
|
||||
_validate_import_sizes,
|
||||
)
|
||||
from app.services.portability.types import ImportExportError
|
||||
|
||||
|
||||
class TestNormalizeOptionalText:
|
||||
def test_returns_stripped_string(self) -> None:
|
||||
assert _normalize_optional_text(" hello ") == "hello"
|
||||
|
||||
def test_returns_none_for_none(self) -> None:
|
||||
assert _normalize_optional_text(None) is None
|
||||
|
||||
def test_returns_none_for_empty_string(self) -> None:
|
||||
assert _normalize_optional_text("") is None
|
||||
|
||||
def test_returns_none_for_whitespace_only(self) -> None:
|
||||
assert _normalize_optional_text(" ") is None
|
||||
|
||||
def test_coerces_non_string_to_string(self) -> None:
|
||||
assert _normalize_optional_text(42) == "42"
|
||||
|
||||
|
||||
class TestNormalizeOptionalYear:
|
||||
def test_valid_year(self) -> None:
|
||||
assert _normalize_optional_year(2024) == 2024
|
||||
|
||||
def test_string_year(self) -> None:
|
||||
assert _normalize_optional_year("1999") == 1999
|
||||
|
||||
def test_returns_none_for_none(self) -> None:
|
||||
assert _normalize_optional_year(None) is None
|
||||
|
||||
def test_returns_none_for_non_numeric(self) -> None:
|
||||
assert _normalize_optional_year("abc") is None
|
||||
|
||||
def test_returns_none_for_year_below_1500(self) -> None:
|
||||
assert _normalize_optional_year(1499) is None
|
||||
|
||||
def test_returns_none_for_year_above_3000(self) -> None:
|
||||
assert _normalize_optional_year(3001) is None
|
||||
|
||||
def test_boundary_1500_accepted(self) -> None:
|
||||
assert _normalize_optional_year(1500) == 1500
|
||||
|
||||
def test_boundary_3000_accepted(self) -> None:
|
||||
assert _normalize_optional_year(3000) == 3000
|
||||
|
||||
|
||||
class TestNormalizeCitationCount:
|
||||
def test_valid_positive(self) -> None:
|
||||
assert _normalize_citation_count(10) == 10
|
||||
|
||||
def test_zero(self) -> None:
|
||||
assert _normalize_citation_count(0) == 0
|
||||
|
||||
def test_negative_clamped_to_zero(self) -> None:
|
||||
assert _normalize_citation_count(-5) == 0
|
||||
|
||||
def test_string_number(self) -> None:
|
||||
assert _normalize_citation_count("42") == 42
|
||||
|
||||
def test_none_returns_zero(self) -> None:
|
||||
assert _normalize_citation_count(None) == 0
|
||||
|
||||
def test_invalid_string_returns_zero(self) -> None:
|
||||
assert _normalize_citation_count("abc") == 0
|
||||
|
||||
|
||||
class TestFirstAuthorLastName:
|
||||
def test_single_author(self) -> None:
|
||||
assert _first_author_last_name("John Smith") == "smith"
|
||||
|
||||
def test_multiple_authors(self) -> None:
|
||||
assert _first_author_last_name("Jane Doe, John Smith") == "doe"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert _first_author_last_name("") == ""
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert _first_author_last_name(None) == ""
|
||||
|
||||
|
||||
class TestFirstVenueWord:
|
||||
def test_returns_first_word(self) -> None:
|
||||
assert _first_venue_word("Nature Communications") == "nature"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert _first_venue_word("") == ""
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert _first_venue_word(None) == ""
|
||||
|
||||
|
||||
class TestBuildFingerprint:
|
||||
def test_produces_64_hex_digest(self) -> None:
|
||||
fp = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
|
||||
assert len(fp) == 64
|
||||
assert all(c in "0123456789abcdef" for c in fp)
|
||||
|
||||
def test_deterministic(self) -> None:
|
||||
kwargs = {"title": "Test Title", "year": 2024, "author_text": "Smith", "venue_text": "ICML"}
|
||||
assert _build_fingerprint(**kwargs) == _build_fingerprint(**kwargs)
|
||||
|
||||
def test_different_titles_produce_different_fingerprints(self) -> None:
|
||||
fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None)
|
||||
fp2 = _build_fingerprint(title="Title B", year=2024, author_text=None, venue_text=None)
|
||||
assert fp1 != fp2
|
||||
|
||||
def test_none_year_handled(self) -> None:
|
||||
fp = _build_fingerprint(title="Test", year=None, author_text=None, venue_text=None)
|
||||
assert len(fp) == 64
|
||||
|
||||
|
||||
class TestResolveFingerprint:
|
||||
def test_uses_provided_valid_sha256(self) -> None:
|
||||
valid_sha = "a" * 64
|
||||
result = _resolve_fingerprint(
|
||||
title="Test",
|
||||
year=2024,
|
||||
author_text=None,
|
||||
venue_text=None,
|
||||
provided_fingerprint=valid_sha,
|
||||
)
|
||||
assert result == valid_sha
|
||||
|
||||
def test_computes_fingerprint_when_provided_is_none(self) -> None:
|
||||
result = _resolve_fingerprint(
|
||||
title="Test",
|
||||
year=2024,
|
||||
author_text=None,
|
||||
venue_text=None,
|
||||
provided_fingerprint=None,
|
||||
)
|
||||
assert len(result) == 64
|
||||
|
||||
def test_computes_fingerprint_when_provided_is_invalid(self) -> None:
|
||||
result = _resolve_fingerprint(
|
||||
title="Test",
|
||||
year=2024,
|
||||
author_text=None,
|
||||
venue_text=None,
|
||||
provided_fingerprint="not-a-sha",
|
||||
)
|
||||
assert len(result) == 64
|
||||
|
||||
def test_normalizes_provided_to_lowercase(self) -> None:
|
||||
valid_sha = "A" * 64
|
||||
result = _resolve_fingerprint(
|
||||
title="Test",
|
||||
year=2024,
|
||||
author_text=None,
|
||||
venue_text=None,
|
||||
provided_fingerprint=valid_sha,
|
||||
)
|
||||
assert result == "a" * 64
|
||||
|
||||
|
||||
class TestValidateImportSizes:
|
||||
def test_accepts_within_limits(self) -> None:
|
||||
_validate_import_sizes(scholars=[{}], publications=[{}])
|
||||
|
||||
def test_rejects_too_many_scholars(self) -> None:
|
||||
with pytest.raises(ImportExportError, match="max scholars"):
|
||||
_validate_import_sizes(
|
||||
scholars=[{}] * (MAX_IMPORT_SCHOLARS + 1),
|
||||
publications=[],
|
||||
)
|
||||
|
||||
def test_rejects_too_many_publications(self) -> None:
|
||||
with pytest.raises(ImportExportError, match="max publications"):
|
||||
_validate_import_sizes(
|
||||
scholars=[],
|
||||
publications=[{}] * (MAX_IMPORT_PUBLICATIONS + 1),
|
||||
)
|
||||
|
||||
def test_accepts_exact_limit(self) -> None:
|
||||
_validate_import_sizes(
|
||||
scholars=[{}] * MAX_IMPORT_SCHOLARS,
|
||||
publications=[{}] * MAX_IMPORT_PUBLICATIONS,
|
||||
)
|
||||
142
tests/unit/test_scholar_validators.py
Normal file
142
tests/unit/test_scholar_validators.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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!")
|
||||
|
||||
|
||||
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"
|
||||
79
tests/unit/test_user_validation.py
Normal file
79
tests/unit/test_user_validation.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.users.application import (
|
||||
UserServiceError,
|
||||
normalize_email,
|
||||
validate_email,
|
||||
validate_password,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeEmail:
|
||||
def test_lowercases_and_strips(self) -> None:
|
||||
assert normalize_email(" Alice@Example.COM ") == "alice@example.com"
|
||||
|
||||
def test_already_normalized(self) -> None:
|
||||
assert normalize_email("user@example.com") == "user@example.com"
|
||||
|
||||
|
||||
class TestValidateEmail:
|
||||
def test_valid_email(self) -> None:
|
||||
assert validate_email("user@example.com") == "user@example.com"
|
||||
|
||||
def test_strips_and_lowercases(self) -> None:
|
||||
assert validate_email(" User@Example.COM ") == "user@example.com"
|
||||
|
||||
def test_rejects_missing_at(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email("userexample.com")
|
||||
|
||||
def test_rejects_missing_domain(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email("user@")
|
||||
|
||||
def test_rejects_missing_tld(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email("user@example")
|
||||
|
||||
def test_rejects_empty_string(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email("")
|
||||
|
||||
def test_rejects_whitespace_only(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email(" ")
|
||||
|
||||
def test_rejects_spaces_in_local_part(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="valid email"):
|
||||
validate_email("us er@example.com")
|
||||
|
||||
def test_accepts_plus_addressing(self) -> None:
|
||||
assert validate_email("user+tag@example.com") == "user+tag@example.com"
|
||||
|
||||
def test_accepts_dots_in_local(self) -> None:
|
||||
assert validate_email("first.last@example.com") == "first.last@example.com"
|
||||
|
||||
|
||||
class TestValidatePassword:
|
||||
def test_valid_password(self) -> None:
|
||||
assert validate_password("securepass") == "securepass"
|
||||
|
||||
def test_exactly_8_characters(self) -> None:
|
||||
assert validate_password("12345678") == "12345678"
|
||||
|
||||
def test_rejects_7_characters(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="at least 8"):
|
||||
validate_password("1234567")
|
||||
|
||||
def test_rejects_empty(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="at least 8"):
|
||||
validate_password("")
|
||||
|
||||
def test_strips_whitespace(self) -> None:
|
||||
assert validate_password(" securepass ") == "securepass"
|
||||
|
||||
def test_whitespace_only_too_short(self) -> None:
|
||||
with pytest.raises(UserServiceError, match="at least 8"):
|
||||
validate_password(" ")
|
||||
Loading…
Add table
Add a link
Reference in a new issue