First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Tests package for shared helper imports.
|
||||
|
||||
171
tests/conftest.py
Normal file
171
tests/conftest.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.auth.deps import get_login_rate_limiter
|
||||
from app.db.session import close_engine
|
||||
from app.settings import settings
|
||||
|
||||
RESET_SQL = text(
|
||||
"""
|
||||
TRUNCATE TABLE
|
||||
ingestion_queue_items,
|
||||
scholar_publications,
|
||||
crawl_runs,
|
||||
scholar_profiles,
|
||||
publications,
|
||||
user_settings,
|
||||
users
|
||||
RESTART IDENTITY CASCADE
|
||||
"""
|
||||
)
|
||||
|
||||
DB_NAME_SAFE_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
def _resolve_test_database_url() -> str | None:
|
||||
explicit = (os.getenv("TEST_DATABASE_URL") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
base = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
|
||||
parsed = make_url(base)
|
||||
if not parsed.database:
|
||||
return None
|
||||
derived_database = (
|
||||
parsed.database if parsed.database.endswith("_test") else f"{parsed.database}_test"
|
||||
)
|
||||
return parsed.set(database=derived_database).render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def pytest_runtest_setup(item: pytest.Item) -> None:
|
||||
if "integration" in item.keywords and not _resolve_test_database_url():
|
||||
pytest.skip("DATABASE_URL (or TEST_DATABASE_URL) is required for integration tests")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def database_url() -> str:
|
||||
value = _resolve_test_database_url()
|
||||
if not value:
|
||||
pytest.skip("DATABASE_URL (or TEST_DATABASE_URL) is required for database tests")
|
||||
return value
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ensure_test_database_exists(database_url: str) -> Iterator[None]:
|
||||
parsed = make_url(database_url)
|
||||
database_name = parsed.database
|
||||
if not database_name:
|
||||
raise RuntimeError("TEST_DATABASE_URL must include a database name.")
|
||||
if not DB_NAME_SAFE_RE.fullmatch(database_name):
|
||||
raise RuntimeError(
|
||||
"TEST_DATABASE_URL database name must match [A-Za-z0-9_]+ for safe auto-provisioning."
|
||||
)
|
||||
|
||||
admin_name = "postgres" if database_name != "postgres" else "template1"
|
||||
admin_url = parsed.set(database=admin_name)
|
||||
admin_url_rendered = admin_url.render_as_string(hide_password=False)
|
||||
|
||||
async def _ensure_database() -> None:
|
||||
engine = create_async_engine(
|
||||
admin_url_rendered,
|
||||
pool_pre_ping=True,
|
||||
isolation_level="AUTOCOMMIT",
|
||||
)
|
||||
try:
|
||||
async with engine.connect() as connection:
|
||||
exists_result = await connection.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :database_name"),
|
||||
{"database_name": database_name},
|
||||
)
|
||||
if exists_result.scalar_one_or_none() == 1:
|
||||
return
|
||||
try:
|
||||
await connection.execute(text(f'CREATE DATABASE "{database_name}"'))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Unable to auto-create test database. "
|
||||
"Create it manually or grant CREATEDB to the database user."
|
||||
) from exc
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_ensure_database())
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def migrated_database(
|
||||
alembic_config: Config,
|
||||
ensure_test_database_exists: None,
|
||||
database_url: str,
|
||||
) -> Iterator[None]:
|
||||
previous_env_database_url = os.getenv("DATABASE_URL")
|
||||
previous_settings_database_url = settings.database_url
|
||||
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
object.__setattr__(settings, "database_url", database_url)
|
||||
asyncio.run(close_engine())
|
||||
command.upgrade(alembic_config, "head")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous_env_database_url is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = previous_env_database_url
|
||||
object.__setattr__(settings, "database_url", previous_settings_database_url)
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(
|
||||
migrated_database: None,
|
||||
database_url: str,
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(RESET_SQL)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limiter_state() -> Iterator[None]:
|
||||
limiter = get_login_rate_limiter()
|
||||
limiter.clear_all()
|
||||
yield
|
||||
limiter.clear_all()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_app_engine() -> AsyncIterator[None]:
|
||||
await close_engine()
|
||||
yield
|
||||
await close_engine()
|
||||
76
tests/fixtures/scholar/profile_ok_amIMrIEAAAAJ.html
vendored
Normal file
76
tests/fixtures/scholar/profile_ok_amIMrIEAAAAJ.html
vendored
Normal file
File diff suppressed because one or more lines are too long
421
tests/fixtures/scholar/regression/profile_AAAAAAAAAAAA.html
vendored
Normal file
421
tests/fixtures/scholar/regression/profile_AAAAAAAAAAAA.html
vendored
Normal file
File diff suppressed because one or more lines are too long
76
tests/fixtures/scholar/regression/profile_LZ5D_p4AAAAJ.html
vendored
Normal file
76
tests/fixtures/scholar/regression/profile_LZ5D_p4AAAAJ.html
vendored
Normal file
File diff suppressed because one or more lines are too long
76
tests/fixtures/scholar/regression/profile_P1RwlvoAAAAJ.html
vendored
Normal file
76
tests/fixtures/scholar/regression/profile_P1RwlvoAAAAJ.html
vendored
Normal file
File diff suppressed because one or more lines are too long
2
tests/integration/__init__.py
Normal file
2
tests/integration/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Integration tests package.
|
||||
|
||||
63
tests/integration/helpers.py
Normal file
63
tests/integration/helpers.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.security import PasswordService
|
||||
|
||||
CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||
|
||||
|
||||
def extract_csrf_token(html: str) -> str:
|
||||
match = CSRF_TOKEN_PATTERN.search(html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def login_user(client: TestClient, *, email: str, password: str) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = extract_csrf_token(login_page.text)
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": email,
|
||||
"password": password,
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
|
||||
|
||||
async def insert_user(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
is_active: bool = True,
|
||||
) -> int:
|
||||
password_service = PasswordService()
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, is_active, is_admin)
|
||||
VALUES (:email, :password_hash, :is_active, :is_admin)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"password_hash": password_service.hash_password(password),
|
||||
"is_active": is_active,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
user_id = int(result.scalar_one())
|
||||
await db_session.commit()
|
||||
return user_id
|
||||
|
||||
200
tests/integration/test_admin_user_management.py
Normal file
200
tests/integration/test_admin_user_management.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
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 extract_csrf_token, insert_user, login_user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_page_is_admin_only(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="member@example.com",
|
||||
password="member-pass",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="member@example.com", password="member-pass")
|
||||
|
||||
response = client.get("/users")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Admin access required."
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_dashboard_hides_users_nav(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="member@example.com",
|
||||
password="member-pass",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="member@example.com", password="member-pass")
|
||||
dashboard = client.get("/")
|
||||
|
||||
assert dashboard.status_code == 200
|
||||
assert ">Users<" not in dashboard.text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_dashboard_shows_users_nav(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="admin@example.com",
|
||||
password="admin-pass",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="admin@example.com", password="admin-pass")
|
||||
dashboard = client.get("/")
|
||||
|
||||
assert dashboard.status_code == 200
|
||||
assert ">Users<" in dashboard.text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_create_and_deactivate_user(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="admin@example.com",
|
||||
password="admin-pass",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="admin@example.com", password="admin-pass")
|
||||
|
||||
users_page = client.get("/users")
|
||||
csrf_token = extract_csrf_token(users_page.text)
|
||||
create_response = client.post(
|
||||
"/users",
|
||||
data={
|
||||
"email": "new-user@example.com",
|
||||
"password": "new-user-pass",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert create_response.status_code == 303
|
||||
assert create_response.headers["location"].startswith("/users")
|
||||
|
||||
created_user_id_result = await db_session.execute(
|
||||
text("SELECT id FROM users WHERE email = 'new-user@example.com'")
|
||||
)
|
||||
created_user_id = int(created_user_id_result.scalar_one())
|
||||
|
||||
users_page_after_create = client.get("/users")
|
||||
csrf_after_create = extract_csrf_token(users_page_after_create.text)
|
||||
toggle_response = client.post(
|
||||
f"/users/{created_user_id}/toggle-active",
|
||||
data={"csrf_token": csrf_after_create},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert toggle_response.status_code == 303
|
||||
|
||||
status_result = await db_session.execute(
|
||||
text("SELECT is_active FROM users WHERE id = :user_id"),
|
||||
{"user_id": created_user_id},
|
||||
)
|
||||
assert status_result.scalar_one() is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_reset_user_password(db_session: AsyncSession) -> None:
|
||||
target_user_id = await insert_user(
|
||||
db_session,
|
||||
email="target@example.com",
|
||||
password="old-password",
|
||||
is_admin=False,
|
||||
)
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="admin@example.com",
|
||||
password="admin-pass",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="admin@example.com", password="admin-pass")
|
||||
|
||||
users_page = client.get("/users")
|
||||
csrf_token = extract_csrf_token(users_page.text)
|
||||
reset_response = client.post(
|
||||
f"/users/{target_user_id}/reset-password",
|
||||
data={
|
||||
"new_password": "new-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert reset_response.status_code == 303
|
||||
|
||||
users_page_after_reset = client.get("/users")
|
||||
logout_csrf = extract_csrf_token(users_page_after_reset.text)
|
||||
client.post(
|
||||
"/logout",
|
||||
data={"csrf_token": logout_csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
failed_login_page = client.get("/login")
|
||||
failed_login_csrf = extract_csrf_token(failed_login_page.text)
|
||||
failed_login = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "target@example.com",
|
||||
"password": "old-password",
|
||||
"csrf_token": failed_login_csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert failed_login.status_code == 401
|
||||
|
||||
login_user(client, email="target@example.com", password="new-password")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_deactivate_self(db_session: AsyncSession) -> None:
|
||||
admin_user_id = await insert_user(
|
||||
db_session,
|
||||
email="admin@example.com",
|
||||
password="admin-pass",
|
||||
is_admin=True,
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_user(client, email="admin@example.com", password="admin-pass")
|
||||
|
||||
users_page = client.get("/users")
|
||||
csrf_token = extract_csrf_token(users_page.text)
|
||||
response = client.post(
|
||||
f"/users/{admin_user_id}/toggle-active",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"].startswith("/users")
|
||||
result = await db_session.execute(
|
||||
text("SELECT is_active FROM users WHERE id = :user_id"),
|
||||
{"user_id": admin_user_id},
|
||||
)
|
||||
assert result.scalar_one() is True
|
||||
518
tests/integration/test_api_v1.py
Normal file
518
tests/integration/test_api_v1.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
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 insert_user, login_user
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@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_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
|
||||
|
||||
|
||||
@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_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", "display_name": "Ada"},
|
||||
)
|
||||
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", "display_name": "Ada"},
|
||||
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_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
|
||||
assert "request_delay_seconds" in get_response.json()["data"]
|
||||
|
||||
update_response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"auto_run_enabled": True,
|
||||
"run_interval_minutes": 45,
|
||||
"request_delay_seconds": 6,
|
||||
},
|
||||
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"] == 45
|
||||
assert updated["request_delay_seconds"] == 6
|
||||
|
||||
|
||||
@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 {"success", "partial_failure", "failed"}
|
||||
assert run_payload["reused_existing_run"] is False
|
||||
assert run_payload["idempotency_key"] == "manual-run-0001"
|
||||
|
||||
replay_response = client.post(
|
||||
"/api/v1/runs/manual",
|
||||
headers={**headers, "Idempotency-Key": "manual-run-0001"},
|
||||
)
|
||||
assert 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
|
||||
|
||||
runs_response = client.get("/api/v1/runs")
|
||||
assert runs_response.status_code == 200
|
||||
assert len(runs_response.json()["data"]["runs"]) >= 1
|
||||
run_id = int(run_payload["run_id"])
|
||||
|
||||
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)
|
||||
|
||||
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_publications_list_and_mark_read(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="api-pubs@example.com",
|
||||
password="api-password",
|
||||
)
|
||||
|
||||
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 isinstance(data["publications"], list)
|
||||
|
||||
mark_response = client.post("/api/v1/publications/mark-all-read", headers=headers)
|
||||
assert mark_response.status_code == 200
|
||||
assert "updated_count" in mark_response.json()["data"]
|
||||
109
tests/integration/test_auth_flow.py
Normal file
109
tests/integration/test_auth_flow.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import re
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth.security import PasswordService
|
||||
from app.main import app
|
||||
|
||||
CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
match = CSRF_TOKEN_PATTERN.search(html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dashboard_requires_authentication() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_with_valid_credentials_allows_access(db_session: AsyncSession) -> None:
|
||||
password_service = PasswordService()
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, is_active, is_admin)
|
||||
VALUES (:email, :password_hash, :is_active, :is_admin)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": "reader@example.com",
|
||||
"password_hash": password_service.hash_password("correct-password"),
|
||||
"is_active": True,
|
||||
"is_admin": False,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
login_response = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "reader@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert login_response.status_code == 303
|
||||
assert login_response.headers["location"] == "/"
|
||||
|
||||
dashboard_response = client.get("/")
|
||||
assert dashboard_response.status_code == 200
|
||||
assert "reader@example.com" in dashboard_response.text
|
||||
assert 'data-test="home-hero"' in dashboard_response.text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_rejects_inactive_user(db_session: AsyncSession) -> None:
|
||||
password_service = PasswordService()
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, is_active, is_admin)
|
||||
VALUES (:email, :password_hash, :is_active, :is_admin)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": "inactive@example.com",
|
||||
"password_hash": password_service.hash_password("correct-password"),
|
||||
"is_active": False,
|
||||
"is_admin": False,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
login_response = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "inactive@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert login_response.status_code == 401
|
||||
assert "Invalid email or password." in login_response.text
|
||||
|
||||
1151
tests/integration/test_manual_ingestion_flow.py
Normal file
1151
tests/integration/test_manual_ingestion_flow.py
Normal file
File diff suppressed because it is too large
Load diff
93
tests/integration/test_migrations.py
Normal file
93
tests/integration/test_migrations.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"alembic_version",
|
||||
"users",
|
||||
"user_settings",
|
||||
"scholar_profiles",
|
||||
"publications",
|
||||
"scholar_publications",
|
||||
"crawl_runs",
|
||||
"ingestion_queue_items",
|
||||
}
|
||||
|
||||
EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
|
||||
EXPECTED_REVISION = "20260217_0004"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.migrations
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_expected_tables(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
)
|
||||
table_names = {row[0] for row in result}
|
||||
assert EXPECTED_TABLES.issubset(table_names)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.migrations
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_registers_expected_enums(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.typname
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
"""
|
||||
)
|
||||
)
|
||||
enum_names = {row[0] for row in result}
|
||||
assert EXPECTED_ENUMS.issubset(enum_names)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.migrations
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_head_revision_is_applied(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(text("SELECT version_num FROM alembic_version"))
|
||||
assert result.scalar_one() == EXPECTED_REVISION
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.migrations
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_table_has_is_admin_column(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'is_admin'
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert result.scalar_one() == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.migrations
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingestion_queue_table_has_status_and_drop_columns(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'ingestion_queue_items'
|
||||
AND column_name IN ('status', 'dropped_reason', 'dropped_at')
|
||||
"""
|
||||
)
|
||||
)
|
||||
columns = {row[0] for row in result}
|
||||
assert columns == {"status", "dropped_reason", "dropped_at"}
|
||||
241
tests/integration/test_multi_user_schema.py
Normal file
241
tests/integration/test_multi_user_schema.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _insert_user(db_session: AsyncSession, email: str) -> int:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash)
|
||||
VALUES (:email, :password_hash)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"email": email, "password_hash": "argon2id$placeholder"},
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.schema
|
||||
@pytest.mark.asyncio
|
||||
async def test_scholar_id_uniqueness_is_scoped_to_user(db_session: AsyncSession) -> None:
|
||||
user_a = await _insert_user(db_session, "owner-a@example.com")
|
||||
user_b = await _insert_user(db_session, "owner-b@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||
VALUES (:user_id, :scholar_id, :display_name)
|
||||
"""
|
||||
),
|
||||
{"user_id": user_a, "scholar_id": "abcDEF123456", "display_name": "Alpha"},
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||
VALUES (:user_id, :scholar_id, :display_name)
|
||||
"""
|
||||
),
|
||||
{"user_id": user_b, "scholar_id": "abcDEF123456", "display_name": "Beta"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||
VALUES (:user_id, :scholar_id, :display_name)
|
||||
"""
|
||||
),
|
||||
{"user_id": user_a, "scholar_id": "abcDEF123456", "display_name": "Gamma"},
|
||||
)
|
||||
await db_session.commit()
|
||||
await db_session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.schema
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_settings_allow_only_one_row_per_user(db_session: AsyncSession) -> None:
|
||||
user_id = await _insert_user(db_session, "settings-owner@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_settings (user_id, auto_run_enabled, run_interval_minutes, request_delay_seconds)
|
||||
VALUES (:user_id, :auto_run_enabled, :run_interval_minutes, :request_delay_seconds)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"auto_run_enabled": True,
|
||||
"run_interval_minutes": 60,
|
||||
"request_delay_seconds": 10,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_settings (user_id, auto_run_enabled, run_interval_minutes, request_delay_seconds)
|
||||
VALUES (:user_id, :auto_run_enabled, :run_interval_minutes, :request_delay_seconds)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"auto_run_enabled": False,
|
||||
"run_interval_minutes": 30,
|
||||
"request_delay_seconds": 5,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
await db_session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.schema
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_run_requires_owner_user(db_session: AsyncSession) -> None:
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO crawl_runs (user_id, trigger_type, status)
|
||||
VALUES (:user_id, :trigger_type, :status)
|
||||
"""
|
||||
),
|
||||
{"user_id": None, "trigger_type": "manual", "status": "running"},
|
||||
)
|
||||
await db_session.commit()
|
||||
await db_session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.schema
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_state_is_isolated_across_users(db_session: AsyncSession) -> None:
|
||||
user_a = await _insert_user(db_session, "reader-a@example.com")
|
||||
user_b = await _insert_user(db_session, "reader-b@example.com")
|
||||
|
||||
scholar_a = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||
VALUES (:user_id, :scholar_id, :display_name)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_a, "scholar_id": "qwerty123456", "display_name": "Reader A"},
|
||||
)
|
||||
scholar_a_id = int(scholar_a.scalar_one())
|
||||
|
||||
scholar_b = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||
VALUES (:user_id, :scholar_id, :display_name)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_b, "scholar_id": "zxcvbn654321", "display_name": "Reader B"},
|
||||
)
|
||||
scholar_b_id = int(scholar_b.scalar_one())
|
||||
|
||||
publication = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year)
|
||||
VALUES (:fingerprint_sha256, :title_raw, :title_normalized, :year)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint_sha256": "f" * 64,
|
||||
"title_raw": "A Shared Paper",
|
||||
"title_normalized": "a shared paper",
|
||||
"year": 2026,
|
||||
},
|
||||
)
|
||||
publication_id = int(publication.scalar_one())
|
||||
|
||||
run_a = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO crawl_runs (user_id, trigger_type, status)
|
||||
VALUES (:user_id, :trigger_type, :status)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_a, "trigger_type": "manual", "status": "success"},
|
||||
)
|
||||
run_a_id = int(run_a.scalar_one())
|
||||
|
||||
run_b = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO crawl_runs (user_id, trigger_type, status)
|
||||
VALUES (:user_id, :trigger_type, :status)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_b, "trigger_type": "manual", "status": "success"},
|
||||
)
|
||||
run_b_id = int(run_b.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, :is_read, :first_seen_run_id)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_a_id,
|
||||
"publication_id": publication_id,
|
||||
"is_read": False,
|
||||
"first_seen_run_id": run_a_id,
|
||||
},
|
||||
)
|
||||
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, :is_read, :first_seen_run_id)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"scholar_profile_id": scholar_b_id,
|
||||
"publication_id": publication_id,
|
||||
"is_read": True,
|
||||
"first_seen_run_id": run_b_id,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT sp.user_id, spp.is_read
|
||||
FROM scholar_publications spp
|
||||
JOIN scholar_profiles sp ON sp.id = spp.scholar_profile_id
|
||||
ORDER BY sp.user_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
assert rows == [(user_a, False), (user_b, True)]
|
||||
|
||||
227
tests/integration/test_queue_ui.py
Normal file
227
tests/integration/test_queue_ui.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
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 extract_csrf_token, insert_user, login_user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_page_queue_actions_lifecycle(db_session: AsyncSession) -> None:
|
||||
user_id = await insert_user(
|
||||
db_session,
|
||||
email="queue-ui@example.com",
|
||||
password="queue-ui-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": "Queue UI 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,
|
||||
last_error,
|
||||
dropped_reason,
|
||||
dropped_at
|
||||
)
|
||||
VALUES (
|
||||
:user_id,
|
||||
:scholar_profile_id,
|
||||
200,
|
||||
'dropped',
|
||||
'dropped',
|
||||
3,
|
||||
NOW() + INTERVAL '30 minutes',
|
||||
'captcha challenge',
|
||||
'max_attempts_after_run',
|
||||
NOW() - INTERVAL '1 minute'
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
},
|
||||
)
|
||||
queue_item_id = int(queue_result.scalar_one())
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="queue-ui@example.com", password="queue-ui-password")
|
||||
|
||||
runs_page = client.get("/runs")
|
||||
assert runs_page.status_code == 200
|
||||
assert "Continuation Queue" in runs_page.text
|
||||
assert "Queue UI Scholar" in runs_page.text
|
||||
assert "dropped" in runs_page.text
|
||||
assert "max_attempts_after_run" in runs_page.text
|
||||
|
||||
csrf_retry = extract_csrf_token(runs_page.text)
|
||||
retry_response = client.post(
|
||||
f"/runs/queue/{queue_item_id}/retry",
|
||||
data={"csrf_token": csrf_retry},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert retry_response.status_code == 303
|
||||
|
||||
queue_after_retry = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT status, reason, attempt_count
|
||||
FROM ingestion_queue_items
|
||||
WHERE id = :queue_item_id
|
||||
"""
|
||||
),
|
||||
{"queue_item_id": queue_item_id},
|
||||
)
|
||||
assert queue_after_retry.one() == ("queued", "manual_retry", 0)
|
||||
|
||||
runs_page_after_retry = client.get("/runs")
|
||||
csrf_drop = extract_csrf_token(runs_page_after_retry.text)
|
||||
drop_response = client.post(
|
||||
f"/runs/queue/{queue_item_id}/drop",
|
||||
data={"csrf_token": csrf_drop},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert drop_response.status_code == 303
|
||||
|
||||
queue_after_drop = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT status, reason, dropped_reason
|
||||
FROM ingestion_queue_items
|
||||
WHERE id = :queue_item_id
|
||||
"""
|
||||
),
|
||||
{"queue_item_id": queue_item_id},
|
||||
)
|
||||
assert queue_after_drop.one() == ("dropped", "dropped", "manual_drop")
|
||||
|
||||
runs_page_after_drop = client.get("/runs")
|
||||
csrf_clear = extract_csrf_token(runs_page_after_drop.text)
|
||||
clear_response = client.post(
|
||||
f"/runs/queue/{queue_item_id}/clear",
|
||||
data={"csrf_token": csrf_clear},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert clear_response.status_code == 303
|
||||
|
||||
queue_after_clear = await db_session.execute(
|
||||
text("SELECT COUNT(*) FROM ingestion_queue_items WHERE id = :queue_item_id"),
|
||||
{"queue_item_id": queue_item_id},
|
||||
)
|
||||
assert queue_after_clear.scalar_one() == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_queue_actions_are_tenant_scoped(db_session: AsyncSession) -> None:
|
||||
user_a_id = await insert_user(
|
||||
db_session,
|
||||
email="queue-owner-a@example.com",
|
||||
password="queue-owner-a-password",
|
||||
)
|
||||
user_b_id = await insert_user(
|
||||
db_session,
|
||||
email="queue-owner-b@example.com",
|
||||
password="queue-owner-b-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_b_id,
|
||||
"scholar_id": "zxyWVU654321",
|
||||
"display_name": "Owner B 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
|
||||
)
|
||||
VALUES (
|
||||
:user_id,
|
||||
:scholar_profile_id,
|
||||
0,
|
||||
'max_pages_reached',
|
||||
'queued',
|
||||
0,
|
||||
NOW()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_b_id,
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
},
|
||||
)
|
||||
queue_item_id = int(queue_result.scalar_one())
|
||||
await db_session.commit()
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="queue-owner-a@example.com", password="queue-owner-a-password")
|
||||
|
||||
runs_page = client.get("/runs")
|
||||
csrf_token = extract_csrf_token(runs_page.text)
|
||||
forbidden_retry = client.post(
|
||||
f"/runs/queue/{queue_item_id}/retry",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert forbidden_retry.status_code == 404
|
||||
|
||||
unchanged = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT status, reason, user_id
|
||||
FROM ingestion_queue_items
|
||||
WHERE id = :queue_item_id
|
||||
"""
|
||||
),
|
||||
{"queue_item_id": queue_item_id},
|
||||
)
|
||||
assert unchanged.one() == ("queued", "max_pages_reached", user_b_id)
|
||||
assert user_a_id != user_b_id
|
||||
197
tests/integration/test_tenant_routes.py
Normal file
197
tests/integration/test_tenant_routes.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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 extract_csrf_token, insert_user, login_user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_can_change_own_password(db_session: AsyncSession) -> None:
|
||||
await insert_user(
|
||||
db_session,
|
||||
email="reader@example.com",
|
||||
password="old-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="reader@example.com", password="old-password")
|
||||
|
||||
account_page = client.get("/account/password")
|
||||
csrf_token = extract_csrf_token(account_page.text)
|
||||
wrong_current = client.post(
|
||||
"/account/password",
|
||||
data={
|
||||
"current_password": "wrong-password",
|
||||
"new_password": "new-password",
|
||||
"confirm_password": "new-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert wrong_current.status_code == 303
|
||||
assert wrong_current.headers["location"].startswith("/account/password")
|
||||
|
||||
account_page_retry = client.get("/account/password")
|
||||
retry_csrf = extract_csrf_token(account_page_retry.text)
|
||||
success = client.post(
|
||||
"/account/password",
|
||||
data={
|
||||
"current_password": "old-password",
|
||||
"new_password": "new-password",
|
||||
"confirm_password": "new-password",
|
||||
"csrf_token": retry_csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert success.status_code == 303
|
||||
|
||||
account_page_after = client.get("/account/password")
|
||||
logout_csrf = extract_csrf_token(account_page_after.text)
|
||||
client.post("/logout", data={"csrf_token": logout_csrf}, follow_redirects=False)
|
||||
|
||||
failed_login_page = client.get("/login")
|
||||
failed_login_csrf = extract_csrf_token(failed_login_page.text)
|
||||
failed_login = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "reader@example.com",
|
||||
"password": "old-password",
|
||||
"csrf_token": failed_login_csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert failed_login.status_code == 401
|
||||
|
||||
login_user(client, email="reader@example.com", password="new-password")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_scholar_routes_are_tenant_scoped(db_session: AsyncSession) -> None:
|
||||
user_a_id = await insert_user(
|
||||
db_session,
|
||||
email="owner-a@example.com",
|
||||
password="owner-a-password",
|
||||
)
|
||||
user_b_id = await insert_user(
|
||||
db_session,
|
||||
email="owner-b@example.com",
|
||||
password="owner-b-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="owner-a@example.com", password="owner-a-password")
|
||||
|
||||
scholars_page = client.get("/scholars")
|
||||
csrf_token = extract_csrf_token(scholars_page.text)
|
||||
create_response = client.post(
|
||||
"/scholars",
|
||||
data={
|
||||
"scholar_id": "abcDEF123456",
|
||||
"display_name": "Owner A Scholar",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert create_response.status_code == 303
|
||||
|
||||
owner_a_row = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id
|
||||
FROM scholar_profiles
|
||||
WHERE scholar_id = 'abcDEF123456'
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert owner_a_row.scalar_one() == user_a_id
|
||||
|
||||
owner_b_profile = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||
VALUES (:user_id, :scholar_id, :display_name, :is_enabled)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_b_id,
|
||||
"scholar_id": "zxcvbn654321",
|
||||
"display_name": "Owner B Scholar",
|
||||
"is_enabled": True,
|
||||
},
|
||||
)
|
||||
owner_b_profile_id = int(owner_b_profile.scalar_one())
|
||||
await db_session.commit()
|
||||
|
||||
scholars_page_after_insert = client.get("/scholars")
|
||||
csrf_after_insert = extract_csrf_token(scholars_page_after_insert.text)
|
||||
forbidden_toggle = client.post(
|
||||
f"/scholars/{owner_b_profile_id}/toggle",
|
||||
data={"csrf_token": csrf_after_insert},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert forbidden_toggle.status_code == 404
|
||||
|
||||
owner_b_status = await db_session.execute(
|
||||
text("SELECT is_enabled FROM scholar_profiles WHERE id = :profile_id"),
|
||||
{"profile_id": owner_b_profile_id},
|
||||
)
|
||||
assert owner_b_status.scalar_one() is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_updates_are_user_scoped(db_session: AsyncSession) -> None:
|
||||
user_a_id = await insert_user(
|
||||
db_session,
|
||||
email="settings-a@example.com",
|
||||
password="settings-a-password",
|
||||
)
|
||||
user_b_id = await insert_user(
|
||||
db_session,
|
||||
email="settings-b@example.com",
|
||||
password="settings-b-password",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
login_user(client, email="settings-a@example.com", password="settings-a-password")
|
||||
|
||||
settings_page = client.get("/settings")
|
||||
csrf_token = extract_csrf_token(settings_page.text)
|
||||
response = client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"auto_run_enabled": "on",
|
||||
"run_interval_minutes": "45",
|
||||
"request_delay_seconds": "7",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
user_a_settings = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT auto_run_enabled, run_interval_minutes, request_delay_seconds
|
||||
FROM user_settings
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_a_id},
|
||||
)
|
||||
assert user_a_settings.one() == (True, 45, 7)
|
||||
|
||||
user_b_settings = await db_session.execute(
|
||||
text("SELECT COUNT(*) FROM user_settings WHERE user_id = :user_id"),
|
||||
{"user_id": user_b_id},
|
||||
)
|
||||
assert user_b_settings.scalar_one() == 0
|
||||
|
||||
23
tests/smoke/test_db_connectivity.py
Normal file
23
tests/smoke/test_db_connectivity.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connectivity_from_database_url() -> None:
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
pytest.skip("DATABASE_URL is not set")
|
||||
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
try:
|
||||
async with engine.connect() as connection:
|
||||
result = await connection.execute(text("SELECT 1"))
|
||||
assert result.scalar_one() == 1
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
46
tests/smoke/test_migration_schema.py
Normal file
46
tests/smoke/test_migration_schema.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_head_revision_is_available(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(text("SELECT version_num FROM alembic_version"))
|
||||
assert result.scalar_one() == "20260217_0004"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_table_exists_in_public_schema(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename = 'users'
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert result.scalar_one() == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_table_includes_admin_column(db_session: AsyncSession) -> None:
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'is_admin'
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert result.scalar_one() == 1
|
||||
204
tests/unit/test_auth.py
Normal file
204
tests/unit/test_auth.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
||||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
from app.db.session import get_db_session
|
||||
from app.main import app
|
||||
|
||||
CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||
|
||||
|
||||
class StubAuthService:
|
||||
def __init__(self, *, user: object | None) -> None:
|
||||
self._user = user
|
||||
|
||||
async def authenticate_user(self, _db_session, *, email: str, password: str):
|
||||
if self._user is None:
|
||||
return None
|
||||
if email.strip().lower() != str(self._user.email):
|
||||
return None
|
||||
if password != "correct-password":
|
||||
return None
|
||||
return self._user
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
match = CSRF_TOKEN_PATTERN.search(html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
async def _override_db_session() -> AsyncIterator[object]:
|
||||
yield object()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_dependency_overrides() -> AsyncIterator[None]:
|
||||
app.dependency_overrides.clear()
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_login_requires_csrf_token() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"email": "user@example.com", "password": "correct-password"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.text == "CSRF token missing."
|
||||
|
||||
|
||||
def test_successful_login_creates_session_and_allows_dashboard(monkeypatch) -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
||||
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
||||
)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
monkeypatch.setattr(
|
||||
"app.web.common.get_authenticated_user",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
||||
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
||||
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
||||
)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
login_response = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "user@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert login_response.status_code == 303
|
||||
assert login_response.headers["location"] == "/"
|
||||
|
||||
dashboard_response = client.get("/")
|
||||
assert dashboard_response.status_code == 200
|
||||
assert 'data-test="home-hero"' in dashboard_response.text
|
||||
assert 'data-test="session-user"' in dashboard_response.text
|
||||
assert "user@example.com" in dashboard_response.text
|
||||
|
||||
|
||||
def test_login_rate_limiting_returns_429_after_threshold() -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=2, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(user=None)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
payload = {
|
||||
"email": "user@example.com",
|
||||
"password": "wrong-password",
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
first = client.post("/login", data=payload, follow_redirects=False)
|
||||
second = client.post("/login", data=payload, follow_redirects=False)
|
||||
third = client.post("/login", data=payload, follow_redirects=False)
|
||||
|
||||
assert first.status_code == 401
|
||||
assert second.status_code == 401
|
||||
assert third.status_code == 429
|
||||
assert third.headers["Retry-After"] == "60"
|
||||
|
||||
|
||||
def test_logout_requires_csrf_token_and_clears_session(monkeypatch) -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
||||
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
||||
)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
monkeypatch.setattr(
|
||||
"app.web.common.get_authenticated_user",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(id=1, email="user@example.com", is_admin=False),
|
||||
None,
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
||||
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
||||
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
||||
)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "user@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
failed_logout = client.post("/logout", data={}, follow_redirects=False)
|
||||
assert failed_logout.status_code == 403
|
||||
assert failed_logout.text == "CSRF token invalid."
|
||||
|
||||
dashboard_page = client.get("/")
|
||||
logout_token = _extract_csrf_token(dashboard_page.text)
|
||||
successful_logout = client.post(
|
||||
"/logout",
|
||||
data={"csrf_token": logout_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert successful_logout.status_code == 303
|
||||
assert successful_logout.headers["location"] == "/login"
|
||||
assert client.get("/", follow_redirects=False).headers["location"] == "/login"
|
||||
34
tests/unit/test_auth_primitives.py
Normal file
34
tests/unit/test_auth_primitives.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
from app.auth.security import PasswordService
|
||||
|
||||
|
||||
def test_password_service_hash_and_verify_round_trip() -> None:
|
||||
password_service = PasswordService()
|
||||
password_hash = password_service.hash_password("example-password")
|
||||
|
||||
assert password_hash.startswith("$argon2id$")
|
||||
assert password_service.verify_password(password_hash, "example-password")
|
||||
assert not password_service.verify_password(password_hash, "wrong-password")
|
||||
|
||||
|
||||
def test_rate_limiter_enforces_windowed_attempt_budget() -> None:
|
||||
clock = {"now": 0.0}
|
||||
limiter = SlidingWindowRateLimiter(
|
||||
max_attempts=2,
|
||||
window_seconds=10,
|
||||
now=lambda: clock["now"],
|
||||
)
|
||||
key = "127.0.0.1:test@example.com"
|
||||
|
||||
assert limiter.check(key).allowed
|
||||
limiter.record_failure(key)
|
||||
assert limiter.check(key).allowed
|
||||
limiter.record_failure(key)
|
||||
|
||||
decision = limiter.check(key)
|
||||
assert not decision.allowed
|
||||
assert decision.retry_after_seconds == 10
|
||||
|
||||
clock["now"] = 11.0
|
||||
assert limiter.check(key).allowed
|
||||
|
||||
88
tests/unit/test_dashboard_view_model.py
Normal file
88
tests/unit/test_dashboard_view_model.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.models import CrawlRun, RunStatus, RunTriggerType
|
||||
from app.presentation.dashboard import SECTION_TEMPLATES, build_dashboard_view_model
|
||||
from app.services.publications import UnreadPublicationItem
|
||||
|
||||
|
||||
def test_build_dashboard_view_model_maps_sections_and_unread_items() -> None:
|
||||
unread_items = [
|
||||
UnreadPublicationItem(
|
||||
publication_id=10,
|
||||
scholar_profile_id=3,
|
||||
scholar_label="Ada Lovelace",
|
||||
title="Analytical Engine Notes",
|
||||
year=None,
|
||||
citation_count=42,
|
||||
venue_text="Computing Letters",
|
||||
pub_url="https://example.test/pub-10",
|
||||
)
|
||||
]
|
||||
runs = [
|
||||
CrawlRun(
|
||||
id=77,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.SUCCESS,
|
||||
start_dt=datetime(2026, 2, 16, 18, 0, tzinfo=timezone.utc),
|
||||
scholar_count=1,
|
||||
new_pub_count=1,
|
||||
error_log={},
|
||||
)
|
||||
]
|
||||
|
||||
vm = build_dashboard_view_model(
|
||||
unread_publications=unread_items,
|
||||
recent_runs=runs,
|
||||
request_delay_seconds=7,
|
||||
queue_counts={"queued": 2, "retrying": 1, "dropped": 3},
|
||||
)
|
||||
|
||||
assert vm.section_templates == SECTION_TEMPLATES
|
||||
assert vm.run_controls.request_delay_seconds == 7
|
||||
assert vm.run_controls.queue_queued_count == 2
|
||||
assert vm.run_controls.queue_retrying_count == 1
|
||||
assert vm.run_controls.queue_dropped_count == 3
|
||||
assert vm.unread_publications[0].title == "Analytical Engine Notes"
|
||||
assert vm.unread_publications[0].year_display == "-"
|
||||
assert vm.unread_publications[0].citation_count == 42
|
||||
assert vm.run_history[0].status_label == "success"
|
||||
assert vm.run_history[0].status_badge == "ok"
|
||||
assert vm.run_history[0].started_at_display == "2026-02-16 18:00 UTC"
|
||||
assert vm.run_history[0].detail_url == "/runs/77"
|
||||
|
||||
|
||||
def test_build_dashboard_view_model_maps_failed_and_partial_statuses() -> None:
|
||||
runs = [
|
||||
CrawlRun(
|
||||
id=11,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.FAILED,
|
||||
start_dt=datetime(2026, 2, 16, 19, 0, tzinfo=timezone.utc),
|
||||
scholar_count=2,
|
||||
new_pub_count=0,
|
||||
error_log={},
|
||||
),
|
||||
CrawlRun(
|
||||
id=12,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.PARTIAL_FAILURE,
|
||||
start_dt=datetime(2026, 2, 16, 20, 0, tzinfo=timezone.utc),
|
||||
scholar_count=2,
|
||||
new_pub_count=1,
|
||||
error_log={},
|
||||
),
|
||||
]
|
||||
|
||||
vm = build_dashboard_view_model(
|
||||
unread_publications=[],
|
||||
recent_runs=runs,
|
||||
request_delay_seconds=1,
|
||||
)
|
||||
|
||||
assert [item.status_badge for item in vm.run_history] == ["danger", "warn"]
|
||||
assert [item.status_label for item in vm.run_history] == ["failed", "partial_failure"]
|
||||
25
tests/unit/test_healthz.py
Normal file
25
tests/unit/test_healthz.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_healthz_returns_200_when_database_is_available(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=True))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_healthz_returns_500_when_database_is_unavailable(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=False))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"] == "database unavailable"
|
||||
52
tests/unit/test_logging.py
Normal file
52
tests/unit/test_logging.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.logging_config import JsonLogFormatter, parse_redact_fields
|
||||
from app.main import app
|
||||
from app.web.middleware import REQUEST_ID_HEADER, parse_skip_paths
|
||||
|
||||
|
||||
def test_json_log_formatter_redacts_sensitive_fields() -> None:
|
||||
formatter = JsonLogFormatter(redact_fields=parse_redact_fields("api_key"))
|
||||
record = logging.makeLogRecord(
|
||||
{
|
||||
"name": "tests.logging",
|
||||
"levelno": logging.INFO,
|
||||
"levelname": "INFO",
|
||||
"msg": "test.event",
|
||||
"args": (),
|
||||
"password": "very-secret",
|
||||
"payload": {
|
||||
"csrf_token": "token-value",
|
||||
"safe": "ok",
|
||||
},
|
||||
"color_message": "ANSI-noise",
|
||||
}
|
||||
)
|
||||
|
||||
payload = json.loads(formatter.format(record))
|
||||
|
||||
assert payload["event"] == "test.event"
|
||||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z", payload["timestamp"])
|
||||
assert payload["password"] == "[REDACTED]"
|
||||
assert payload["payload"]["csrf_token"] == "[REDACTED]"
|
||||
assert payload["payload"]["safe"] == "ok"
|
||||
assert "color_message" not in payload
|
||||
|
||||
|
||||
def test_request_logging_middleware_sets_request_id_header() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login", headers={REQUEST_ID_HEADER: "request-123"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers[REQUEST_ID_HEADER] == "request-123"
|
||||
|
||||
|
||||
def test_parse_skip_paths_trims_and_discards_empty_segments() -> None:
|
||||
assert parse_skip_paths(" /healthz , , /static/ ") == ("/healthz", "/static/")
|
||||
54
tests/unit/test_pages.py
Normal file
54
tests/unit/test_pages.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_home_page_redirects_to_login_when_unauthenticated() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_dev_ui_page_is_removed() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/dev/ui", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_login_page_renders_html() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert "scholarr" in response.text
|
||||
assert 'data-test="login-form"' in response.text
|
||||
assert 'name="csrf_token"' in response.text
|
||||
assert 'data-theme-control' in response.text
|
||||
set_cookie = response.headers["set-cookie"].lower()
|
||||
assert "httponly" in set_cookie
|
||||
assert "samesite=lax" in set_cookie
|
||||
|
||||
|
||||
def test_theme_query_parameter_accepts_supported_theme() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login?theme=spruce")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-theme="spruce"' in response.text
|
||||
|
||||
|
||||
def test_theme_query_parameter_falls_back_for_unknown_theme() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login?theme=not-a-theme")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-theme="terracotta"' in response.text
|
||||
20
tests/unit/test_phase2_pages.py
Normal file
20
tests/unit/test_phase2_pages.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_phase2_pages_redirect_to_login_when_unauthenticated() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
for path in (
|
||||
"/users",
|
||||
"/runs",
|
||||
"/runs/1",
|
||||
"/publications",
|
||||
"/scholars",
|
||||
"/settings",
|
||||
"/account/password",
|
||||
):
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
240
tests/unit/test_scholar_parser.py
Normal file
240
tests/unit/test_scholar_parser.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.scholar_parser import ParseState, parse_profile_page
|
||||
from app.services.scholar_source import FetchResult
|
||||
|
||||
|
||||
def _fixture(name: str) -> str:
|
||||
path = Path("tests/fixtures/scholar") / name
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _regression_fixture(name: str) -> str:
|
||||
path = Path("tests/fixtures/scholar/regression") / name
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_parse_profile_page_extracts_core_fields_from_fixture() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ",
|
||||
body=_fixture("profile_ok_amIMrIEAAAAJ.html"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.state_reason == "publications_extracted"
|
||||
assert parsed.profile_name == "Bangar Raju Cherukuri"
|
||||
assert len(parsed.publications) >= 10
|
||||
assert parsed.has_show_more_button is True
|
||||
assert parsed.articles_range is not None
|
||||
first = parsed.publications[0]
|
||||
assert first.title
|
||||
assert first.cluster_id
|
||||
assert first.citation_count is not None
|
||||
|
||||
|
||||
def test_parse_profile_page_classifies_accounts_redirect_as_blocked() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=AAAAAAAAAAAA",
|
||||
status_code=200,
|
||||
final_url="https://accounts.google.com/v3/signin/identifier?continue=...",
|
||||
body="<html><body>Sign in</body></html>",
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.BLOCKED_OR_CAPTCHA
|
||||
assert parsed.state_reason == "blocked_accounts_redirect"
|
||||
assert len(parsed.publications) == 0
|
||||
|
||||
|
||||
def test_parse_profile_page_handles_missing_optional_metadata() -> None:
|
||||
html = """
|
||||
<html>
|
||||
<div id="gsc_prf_in">Test Author</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=abc:def123">A Test Paper</a>
|
||||
<div class="gs_gray">A Person</div>
|
||||
</td>
|
||||
<td class="gsc_a_c"><a class="gsc_a_ac">7</a></td>
|
||||
<td class="gsc_a_y"><span class="gsc_a_h"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</html>
|
||||
"""
|
||||
fetch_result = 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,
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.state_reason == "publications_extracted"
|
||||
assert len(parsed.publications) == 1
|
||||
publication = parsed.publications[0]
|
||||
assert publication.year is None
|
||||
assert publication.venue_text is None
|
||||
|
||||
|
||||
def test_parse_profile_page_detects_layout_change_when_markers_absent() -> None:
|
||||
fetch_result = 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><body><h1>Unexpected page</h1></body></html>",
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.LAYOUT_CHANGED
|
||||
assert parsed.state_reason == "layout_markers_missing"
|
||||
assert "no_rows_detected" in parsed.warnings
|
||||
|
||||
|
||||
def test_parse_profile_page_reports_network_reason_when_status_missing() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
|
||||
status_code=None,
|
||||
final_url=None,
|
||||
body="",
|
||||
error="timed out",
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.NETWORK_ERROR
|
||||
assert parsed.state_reason == "network_error_missing_status_code"
|
||||
|
||||
|
||||
def test_parse_profile_page_ignores_no_results_keyword_inside_script_blocks() -> None:
|
||||
html = """
|
||||
<html>
|
||||
<script>
|
||||
const message = "didn't match any articles";
|
||||
</script>
|
||||
<div id="gsc_prf_in">Scripted Author</div>
|
||||
<table><tbody id="gsc_a_b"></tbody></table>
|
||||
</html>
|
||||
"""
|
||||
fetch_result = 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,
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.state_reason == "no_rows_with_known_markers"
|
||||
|
||||
|
||||
def test_parse_profile_page_treats_disabled_show_more_button_as_absent() -> None:
|
||||
html = """
|
||||
<html>
|
||||
<div id="gsc_prf_in">Disabled Show More</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=abc:def">Paper</a>
|
||||
</td>
|
||||
<td class="gsc_a_c"><a class="gsc_a_ac">1</a></td>
|
||||
<td class="gsc_a_y"><span class="gsc_a_h">2024</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<button id="gsc_bpf_more" disabled>Show more</button>
|
||||
</html>
|
||||
"""
|
||||
fetch_result = 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,
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.has_show_more_button is False
|
||||
|
||||
|
||||
def test_parse_profile_page_regression_fixture_profile_p1rwlvo() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ",
|
||||
body=_regression_fixture("profile_P1RwlvoAAAAJ.html"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.state_reason == "publications_extracted"
|
||||
assert parsed.profile_name == "WENRUI ZUO"
|
||||
assert len(parsed.publications) == 5
|
||||
assert parsed.has_show_more_button is False
|
||||
assert parsed.articles_range in {"Articles 1-5", "Articles 1–5"}
|
||||
assert "possible_partial_page_show_more_present" not in parsed.warnings
|
||||
assert all(item.cluster_id for item in parsed.publications)
|
||||
|
||||
|
||||
def test_parse_profile_page_regression_fixture_profile_lz5d() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ",
|
||||
status_code=200,
|
||||
final_url="https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ",
|
||||
body=_regression_fixture("profile_LZ5D_p4AAAAJ.html"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert parsed.state_reason == "publications_extracted"
|
||||
assert parsed.profile_name == "Doaa Elmatary"
|
||||
assert len(parsed.publications) == 12
|
||||
assert parsed.has_show_more_button is False
|
||||
assert parsed.articles_range in {"Articles 1-12", "Articles 1–12"}
|
||||
assert "possible_partial_page_show_more_present" not in parsed.warnings
|
||||
assert any(item.venue_text is None for item in parsed.publications)
|
||||
|
||||
|
||||
def test_parse_profile_page_regression_fixture_blocked_redirect() -> None:
|
||||
fetch_result = FetchResult(
|
||||
requested_url="https://scholar.google.com/citations?hl=en&user=AAAAAAAAAAAA",
|
||||
status_code=200,
|
||||
final_url=(
|
||||
"https://accounts.google.com/v3/signin/identifier"
|
||||
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA"
|
||||
),
|
||||
body=_regression_fixture("profile_AAAAAAAAAAAA.html"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.BLOCKED_OR_CAPTCHA
|
||||
assert parsed.state_reason == "blocked_accounts_redirect"
|
||||
assert parsed.profile_name is None
|
||||
assert len(parsed.publications) == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue