scholarr/tests/integration/test_api_scholars_bulk.py
Justin Visser f501ea4f94 fix(frontend): confirm modal, settings display, race guard, and test coverage
- S1: Replace window.confirm() with AppConfirmModal for delete actions
- S4: Add formatHours helper to avoid ugly decimals in settings hints
- W5: Add generation counter to SettingsAdminPanel to prevent stale
  async results when section changes rapidly
- S7: Remove unused ApiRequestError import from useScholarBulkActions
- S2: Add error-path tests for bulk delete/toggle network failures
- S3: Add CSRF boundary tests for bulk-delete and bulk-toggle endpoints
2026-03-07 18:07:29 +01:00

170 lines
5.8 KiB
Python

from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from tests.integration.helpers import (
api_csrf_headers,
insert_user,
login_user,
)
def _create_scholar(client: TestClient, headers: dict, scholar_id: str) -> int:
resp = client.post("/api/v1/scholars", json={"scholar_id": scholar_id}, headers=headers)
assert resp.status_code == 201
return int(resp.json()["data"]["id"])
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_with_valid_ids(db_session: AsyncSession) -> None:
await insert_user(db_session, email="bulk@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="bulk@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
id2 = _create_scholar(client, headers, "cccDDD333444")
resp = client.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [id1, id2]},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["deleted_count"] == 2
list_resp = client.get("/api/v1/scholars")
assert len(list_resp.json()["data"]["scholars"]) == 0
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_only_deletes_own_scholars(db_session: AsyncSession) -> None:
await insert_user(db_session, email="user1@example.com", password="pw123456")
await insert_user(db_session, email="user2@example.com", password="pw123456")
client1 = TestClient(app)
login_user(client1, email="user1@example.com", password="pw123456")
headers1 = api_csrf_headers(client1)
client2 = TestClient(app)
login_user(client2, email="user2@example.com", password="pw123456")
headers2 = api_csrf_headers(client2)
id_user1 = _create_scholar(client1, headers1, "aaaBBB111222")
id_user2 = _create_scholar(client2, headers2, "cccDDD333444")
# User1 tries to delete both — should only delete own
resp = client1.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [id_user1, id_user2]},
headers=headers1,
)
assert resp.status_code == 200
assert resp.json()["data"]["deleted_count"] == 1
# User2's scholar still exists
list_resp = client2.get("/api/v1/scholars")
scholars = list_resp.json()["data"]["scholars"]
assert len(scholars) == 1
assert int(scholars[0]["id"]) == id_user2
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_toggle_enables_and_disables(db_session: AsyncSession) -> None:
await insert_user(db_session, email="toggle@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="toggle@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
id2 = _create_scholar(client, headers, "cccDDD333444")
# Disable both
resp = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [id1, id2], "is_enabled": False},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["updated_count"] == 2
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
for s in scholars:
assert s["is_enabled"] is False
# Re-enable both
resp = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [id1, id2], "is_enabled": True},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["updated_count"] == 2
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
for s in scholars:
assert s["is_enabled"] is True
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
await insert_user(db_session, email="export@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="export@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
_create_scholar(client, headers, "cccDDD333444")
# Export only id1
resp = client.get(f"/api/v1/scholars/export?ids={id1}")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data["scholars"]) == 1
assert data["scholars"][0]["scholar_id"] == "aaaBBB111222"
# Export all (no filter)
resp_all = client.get("/api/v1/scholars/export")
assert resp_all.status_code == 200
assert len(resp_all.json()["data"]["scholars"]) == 2
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-del@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-del@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [1]},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_toggle_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-tog@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-tog@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [1], "is_enabled": False},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"