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

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