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