First product

This commit is contained in:
Justin Visser 2026-02-17 14:51:25 +01:00
parent 778da9e2fc
commit 4433d7d2c4
157 changed files with 23975 additions and 0 deletions

2
tests/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Tests package for shared helper imports.

171
tests/conftest.py Normal file
View 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()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
# Integration tests package.

View 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

View 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

View 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"]

View 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

File diff suppressed because it is too large Load diff

View 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"}

View 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)]

View 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

View 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

View 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()

View 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
View 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"

View 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

View 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"]

View 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"

View 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
View 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

View 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"

View 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 15"}
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 112"}
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