feature: create multitasking
This commit is contained in:
+217
-17
@@ -4,12 +4,22 @@ Shared test fixtures and configuration.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest # type: ignore[import-not-found]
|
||||
from fastapi.testclient import TestClient
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from cpv3.db.base import Base
|
||||
from cpv3.db.session import get_db
|
||||
from cpv3.infrastructure.auth import get_current_user
|
||||
from cpv3.infrastructure.deps import get_storage
|
||||
from cpv3.infrastructure.security import create_token, hash_password
|
||||
from cpv3.main import app
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
# Use in-memory SQLite for tests (or configure a test database)
|
||||
@@ -17,28 +27,218 @@ TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
"""Create a test client for the FastAPI app."""
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
async def test_engine():
|
||||
"""Create a test database engine with tables."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db_session():
|
||||
"""Create a test database session."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def test_db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a test database session with per-test transaction isolation."""
|
||||
async_session = async_sessionmaker(
|
||||
bind=engine, class_=AsyncSession, expire_on_commit=False
|
||||
bind=test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
@pytest.fixture
|
||||
async def test_user(test_db_session: AsyncSession) -> User:
|
||||
"""Create a regular test user."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password_hash=hash_password("testpassword"),
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
is_superuser=False,
|
||||
)
|
||||
test_db_session.add(user)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def staff_user(test_db_session: AsyncSession) -> User:
|
||||
"""Create a staff test user."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
username="staffuser",
|
||||
email="staff@example.com",
|
||||
password_hash=hash_password("staffpassword"),
|
||||
first_name="Staff",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_staff=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
test_db_session.add(user)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_user(test_db_session: AsyncSession) -> User:
|
||||
"""Create another regular user for permission testing."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
username="otheruser",
|
||||
email="other@example.com",
|
||||
password_hash=hash_password("otherpassword"),
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
is_superuser=False,
|
||||
)
|
||||
test_db_session.add(user)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user: User) -> dict[str, str]:
|
||||
"""Generate auth headers with valid JWT for the test user."""
|
||||
token = create_token(
|
||||
subject=str(test_user.id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(hours=1),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_auth_headers(staff_user: User) -> dict[str, str]:
|
||||
"""Generate auth headers with valid JWT for the staff user."""
|
||||
token = create_token(
|
||||
subject=str(staff_user.id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(hours=1),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_auth_headers(other_user: User) -> dict[str, str]:
|
||||
"""Generate auth headers with valid JWT for the other user."""
|
||||
token = create_token(
|
||||
subject=str(other_user.id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(hours=1),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage() -> MagicMock:
|
||||
"""Create a mock storage service."""
|
||||
storage = MagicMock()
|
||||
storage.upload_fileobj = AsyncMock(return_value="uploads/test-file.txt")
|
||||
storage.exists = AsyncMock(return_value=True)
|
||||
|
||||
file_info = MagicMock()
|
||||
file_info.file_path = "uploads/test-file.txt"
|
||||
file_info.file_url = "http://example.com/uploads/test-file.txt"
|
||||
file_info.file_size = 1024
|
||||
file_info.filename = "test-file.txt"
|
||||
storage.get_file_info = AsyncMock(return_value=file_info)
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client(
|
||||
test_db_session: AsyncSession,
|
||||
mock_storage: MagicMock,
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create async test client with dependency overrides (no auth override)."""
|
||||
|
||||
async def override_get_db():
|
||||
yield test_db_session
|
||||
|
||||
async def override_get_storage():
|
||||
return mock_storage
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_storage] = override_get_storage
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(
|
||||
test_db_session: AsyncSession,
|
||||
test_user: User,
|
||||
mock_storage: MagicMock,
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create async test client with auth dependency overridden to return test_user."""
|
||||
|
||||
async def override_get_db():
|
||||
yield test_db_session
|
||||
|
||||
def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
async def override_get_storage():
|
||||
return mock_storage
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_storage] = override_get_storage
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def staff_client(
|
||||
test_db_session: AsyncSession,
|
||||
staff_user: User,
|
||||
mock_storage: MagicMock,
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create async test client with auth dependency overridden to return staff_user."""
|
||||
|
||||
async def override_get_db():
|
||||
yield test_db_session
|
||||
|
||||
def override_get_current_user():
|
||||
return staff_user
|
||||
|
||||
async def override_get_storage():
|
||||
return mock_storage
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_storage] = override_get_storage
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Tests for authentication endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from cpv3.infrastructure.security import create_token
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
class TestRegisterEndpoint:
|
||||
"""Tests for POST /auth/register."""
|
||||
|
||||
async def test_register_success(self, async_client: AsyncClient):
|
||||
"""Test successful user registration."""
|
||||
response = await async_client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "securepassword123",
|
||||
"first_name": "New",
|
||||
"last_name": "User",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
assert data["user"]["username"] == "newuser"
|
||||
assert data["user"]["email"] == "newuser@example.com"
|
||||
|
||||
async def test_register_duplicate_username(
|
||||
self, async_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test registration fails with duplicate username."""
|
||||
response = await async_client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": test_user.username, # existing username
|
||||
"email": "another@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_register_missing_required_fields(self, async_client: AsyncClient):
|
||||
"""Test registration fails with missing required fields."""
|
||||
response = await async_client.post(
|
||||
"/auth/register",
|
||||
json={"username": "someuser"}, # missing email and password
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
"""Tests for POST /auth/login."""
|
||||
|
||||
async def test_login_success(self, async_client: AsyncClient, test_user: User):
|
||||
"""Test successful login."""
|
||||
response = await async_client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"password": "testpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
assert data["user"]["username"] == "testuser"
|
||||
|
||||
async def test_login_invalid_password(
|
||||
self, async_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test login fails with wrong password."""
|
||||
response = await async_client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid credentials"
|
||||
|
||||
async def test_login_nonexistent_user(self, async_client: AsyncClient):
|
||||
"""Test login fails for nonexistent user."""
|
||||
response = await async_client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "nonexistent",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid credentials"
|
||||
|
||||
|
||||
class TestRefreshEndpoint:
|
||||
"""Tests for POST /auth/refresh."""
|
||||
|
||||
async def test_refresh_success(self, async_client: AsyncClient, test_user: User):
|
||||
"""Test successful token refresh."""
|
||||
refresh_token = create_token(
|
||||
subject=str(test_user.id),
|
||||
token_type="refresh",
|
||||
expires_in=timedelta(days=7),
|
||||
)
|
||||
|
||||
response = await async_client.post(
|
||||
"/auth/refresh",
|
||||
json={"refresh": refresh_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
|
||||
async def test_refresh_with_access_token_fails(
|
||||
self, async_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test refresh fails when using access token instead of refresh token."""
|
||||
access_token = create_token(
|
||||
subject=str(test_user.id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(minutes=15),
|
||||
)
|
||||
|
||||
response = await async_client.post(
|
||||
"/auth/refresh",
|
||||
json={"refresh": access_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid refresh token"
|
||||
|
||||
async def test_refresh_with_invalid_token(self, async_client: AsyncClient):
|
||||
"""Test refresh fails with invalid token."""
|
||||
response = await async_client.post(
|
||||
"/auth/refresh",
|
||||
json={"refresh": "invalid.token.here"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid refresh token"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Tests for captions endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestGetVideoEndpoint:
|
||||
"""Tests for POST /api/captions/get_video/."""
|
||||
|
||||
async def test_get_video_success(self, auth_client: AsyncClient):
|
||||
"""Test caption burn-in endpoint returns result."""
|
||||
mock_transcription = {
|
||||
"segments": [
|
||||
{
|
||||
"text": "Hello world",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 2.0},
|
||||
"lines": [
|
||||
{
|
||||
"text": "Hello world",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 2.0},
|
||||
"words": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cpv3.modules.captions.router.generate_captions",
|
||||
new_callable=AsyncMock,
|
||||
return_value="uploads/output/captioned_video.mp4",
|
||||
):
|
||||
response = await auth_client.post(
|
||||
"/api/captions/get_video/",
|
||||
json={
|
||||
"folder": "output",
|
||||
"video_s3_path": "uploads/source_video.mp4",
|
||||
"transcription": mock_transcription,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert data["result"] == "uploads/output/captioned_video.mp4"
|
||||
|
||||
async def test_get_video_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test caption burn-in without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/captions/get_video/",
|
||||
json={
|
||||
"folder": "output",
|
||||
"video_s3_path": "test.mp4",
|
||||
"transcription": {"segments": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_get_video_missing_fields(self, auth_client: AsyncClient):
|
||||
"""Test caption burn-in with missing required fields returns 422."""
|
||||
response = await auth_client.post(
|
||||
"/api/captions/get_video/",
|
||||
json={
|
||||
"folder": "output",
|
||||
# missing video_s3_path and transcription
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Tests for file management endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.files.models import File
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_file(test_db_session: AsyncSession, test_user: User) -> File:
|
||||
"""Create a test file entry owned by test_user."""
|
||||
file = File(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=test_user.id,
|
||||
original_filename="test-document.pdf",
|
||||
path="uploads/test-document.pdf",
|
||||
storage_backend="LOCAL",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1024,
|
||||
is_uploaded=True,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(file)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(file)
|
||||
return file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_file(test_db_session: AsyncSession, other_user: User) -> File:
|
||||
"""Create a file entry owned by another user."""
|
||||
file = File(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=other_user.id,
|
||||
original_filename="other-document.pdf",
|
||||
path="uploads/other-document.pdf",
|
||||
storage_backend="LOCAL",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=2048,
|
||||
is_uploaded=True,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(file)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(file)
|
||||
return file
|
||||
|
||||
|
||||
class TestUploadFileEndpoint:
|
||||
"""Tests for POST /api/files/upload/."""
|
||||
|
||||
async def test_upload_file_success(self, auth_client: AsyncClient):
|
||||
"""Test successful file upload."""
|
||||
file_content = b"test file content"
|
||||
files = {"file": ("testfile.txt", io.BytesIO(file_content), "text/plain")}
|
||||
data = {"folder": "uploads"}
|
||||
|
||||
response = await auth_client.post("/api/files/upload/", files=files, data=data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "file_path" in data
|
||||
assert "file_url" in data
|
||||
|
||||
async def test_upload_file_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test uploading file without auth returns 401."""
|
||||
file_content = b"test file content"
|
||||
files = {"file": ("testfile.txt", io.BytesIO(file_content), "text/plain")}
|
||||
|
||||
response = await async_client.post("/api/files/upload/", files=files)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestGetFileInfoEndpoint:
|
||||
"""Tests for GET /api/files/get_file/."""
|
||||
|
||||
async def test_get_file_info_success(self, auth_client: AsyncClient):
|
||||
"""Test getting file info by path."""
|
||||
response = await auth_client.get(
|
||||
"/api/files/get_file/", params={"file_path": "uploads/test-file.txt"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "file_path" in data
|
||||
assert "file_url" in data
|
||||
|
||||
async def test_get_file_info_not_found(
|
||||
self, auth_client: AsyncClient, mock_storage
|
||||
):
|
||||
"""Test getting info for nonexistent file returns 404."""
|
||||
mock_storage.exists.return_value = False
|
||||
|
||||
response = await auth_client.get(
|
||||
"/api/files/get_file/", params={"file_path": "nonexistent/file.txt"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_get_file_info_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test getting file info without auth returns 401."""
|
||||
response = await async_client.get(
|
||||
"/api/files/get_file/", params={"file_path": "uploads/test.txt"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestListFileEntriesEndpoint:
|
||||
"""Tests for GET /api/files/files/."""
|
||||
|
||||
async def test_list_file_entries(self, auth_client: AsyncClient, test_file: File):
|
||||
"""Test listing file entries."""
|
||||
response = await auth_client.get("/api/files/files/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_file_entries_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing file entries without auth returns 401."""
|
||||
response = await async_client.get("/api/files/files/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateFileEntryEndpoint:
|
||||
"""Tests for POST /api/files/files/."""
|
||||
|
||||
async def test_create_file_entry_success(self, auth_client: AsyncClient):
|
||||
"""Test creating a file entry."""
|
||||
response = await auth_client.post(
|
||||
"/api/files/files/",
|
||||
json={
|
||||
"original_filename": "new-file.pdf",
|
||||
"path": "uploads/new-file.pdf",
|
||||
"storage_backend": "LOCAL",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 4096,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["original_filename"] == "new-file.pdf"
|
||||
assert data["path"] == "uploads/new-file.pdf"
|
||||
|
||||
async def test_create_file_entry_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating file entry without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/files/files/",
|
||||
json={
|
||||
"original_filename": "test.pdf",
|
||||
"path": "test.pdf",
|
||||
"storage_backend": "LOCAL",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 1024,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveFileEntryEndpoint:
|
||||
"""Tests for GET /api/files/files/{file_id}/."""
|
||||
|
||||
async def test_retrieve_own_file_entry(
|
||||
self, auth_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test retrieving own file entry."""
|
||||
response = await auth_client.get(f"/api/files/files/{test_file.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_file.id)
|
||||
assert data["original_filename"] == test_file.original_filename
|
||||
|
||||
async def test_retrieve_other_file_as_staff(
|
||||
self, staff_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test staff can retrieve any file entry."""
|
||||
response = await staff_client.get(f"/api/files/files/{test_file.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_retrieve_nonexistent_file_entry(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent file entry returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/files/files/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_retrieve_other_file_forbidden(
|
||||
self, auth_client: AsyncClient, other_file: File
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's file entry."""
|
||||
response = await auth_client.get(f"/api/files/files/{other_file.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPatchFileEntryEndpoint:
|
||||
"""Tests for PATCH /api/files/files/{file_id}/."""
|
||||
|
||||
async def test_patch_own_file_entry(
|
||||
self, auth_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test updating own file entry."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/files/files/{test_file.id}/",
|
||||
json={"original_filename": "renamed-file.pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["original_filename"] == "renamed-file.pdf"
|
||||
|
||||
async def test_patch_other_file_as_staff(
|
||||
self, staff_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test staff can update any file entry."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/files/files/{test_file.id}/",
|
||||
json={"original_filename": "staff-renamed.pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_patch_nonexistent_file_entry(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent file entry returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/files/files/{fake_id}/",
|
||||
json={"original_filename": "test.pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_file_forbidden(
|
||||
self, auth_client: AsyncClient, other_file: File
|
||||
):
|
||||
"""Test regular user cannot update other user's file entry."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/files/files/{other_file.id}/",
|
||||
json={"original_filename": "hacked.pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteFileEntryEndpoint:
|
||||
"""Tests for DELETE /api/files/files/{file_id}/."""
|
||||
|
||||
async def test_delete_own_file_entry(
|
||||
self, auth_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test deleting own file entry."""
|
||||
response = await auth_client.delete(f"/api/files/files/{test_file.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_file_as_staff(
|
||||
self, staff_client: AsyncClient, test_file: File
|
||||
):
|
||||
"""Test staff can delete any file entry."""
|
||||
response = await staff_client.delete(f"/api/files/files/{test_file.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_file_entry(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent file entry returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/files/files/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_file_forbidden(
|
||||
self, auth_client: AsyncClient, other_file: File
|
||||
):
|
||||
"""Test regular user cannot delete other user's file entry."""
|
||||
response = await auth_client.delete(f"/api/files/files/{other_file.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Tests for jobs and events endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.jobs.models import Job, JobEvent
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_job(test_db_session: AsyncSession, test_user: User) -> Job:
|
||||
"""Create a test job owned by test_user."""
|
||||
job = Job(
|
||||
id=uuid.uuid4(),
|
||||
broker_id="test-broker-123",
|
||||
user_id=test_user.id,
|
||||
status="PENDING",
|
||||
job_type="PENDING",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(job)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_job(test_db_session: AsyncSession, other_user: User) -> Job:
|
||||
"""Create a job owned by another user."""
|
||||
job = Job(
|
||||
id=uuid.uuid4(),
|
||||
broker_id="other-broker-456",
|
||||
user_id=other_user.id,
|
||||
status="RUNNING",
|
||||
job_type="RUNNING",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(job)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_event(test_db_session: AsyncSession, test_job: Job) -> JobEvent:
|
||||
"""Create a test job event linked to test_job."""
|
||||
event = JobEvent(
|
||||
id=uuid.uuid4(),
|
||||
job_id=test_job.id,
|
||||
event_type="started",
|
||||
payload={"message": "Job started"},
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(event)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
class TestListJobsEndpoint:
|
||||
"""Tests for GET /api/jobs/jobs/."""
|
||||
|
||||
async def test_list_jobs(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test listing jobs."""
|
||||
response = await auth_client.get("/api/jobs/jobs/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_jobs_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing jobs without auth returns 401."""
|
||||
response = await async_client.get("/api/jobs/jobs/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateJobEndpoint:
|
||||
"""Tests for POST /api/jobs/jobs/."""
|
||||
|
||||
async def test_create_job_success(self, auth_client: AsyncClient):
|
||||
"""Test creating a job."""
|
||||
response = await auth_client.post(
|
||||
"/api/jobs/jobs/",
|
||||
json={
|
||||
"broker_id": "new-broker-789",
|
||||
"status": "PENDING",
|
||||
"job_type": "PENDING",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["broker_id"] == "new-broker-789"
|
||||
assert data["status"] == "PENDING"
|
||||
|
||||
async def test_create_job_with_input_data(self, auth_client: AsyncClient):
|
||||
"""Test creating a job with input data."""
|
||||
response = await auth_client.post(
|
||||
"/api/jobs/jobs/",
|
||||
json={
|
||||
"broker_id": "broker-with-data",
|
||||
"input_data": {"file_path": "uploads/test.mp4"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["input_data"]["file_path"] == "uploads/test.mp4"
|
||||
|
||||
async def test_create_job_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating job without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/jobs/jobs/",
|
||||
json={"broker_id": "test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveJobEndpoint:
|
||||
"""Tests for GET /api/jobs/jobs/{job_id}/."""
|
||||
|
||||
async def test_retrieve_own_job(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test retrieving own job."""
|
||||
response = await auth_client.get(f"/api/jobs/jobs/{test_job.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_job.id)
|
||||
assert data["broker_id"] == test_job.broker_id
|
||||
|
||||
async def test_retrieve_other_job_as_staff(
|
||||
self, staff_client: AsyncClient, test_job: Job
|
||||
):
|
||||
"""Test staff can retrieve any job."""
|
||||
response = await staff_client.get(f"/api/jobs/jobs/{test_job.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_retrieve_nonexistent_job(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent job returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/jobs/jobs/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_retrieve_other_job_forbidden(
|
||||
self, auth_client: AsyncClient, other_job: Job
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's job."""
|
||||
response = await auth_client.get(f"/api/jobs/jobs/{other_job.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPatchJobEndpoint:
|
||||
"""Tests for PATCH /api/jobs/jobs/{job_id}/."""
|
||||
|
||||
async def test_patch_own_job(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test updating own job."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/jobs/{test_job.id}/",
|
||||
json={"status": "RUNNING", "current_message": "Processing..."},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "RUNNING"
|
||||
assert data["current_message"] == "Processing..."
|
||||
|
||||
async def test_patch_job_progress(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test updating job progress."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/jobs/{test_job.id}/",
|
||||
json={"project_pct": 50.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["project_pct"] == 50.0
|
||||
|
||||
async def test_patch_other_job_as_staff(
|
||||
self, staff_client: AsyncClient, test_job: Job
|
||||
):
|
||||
"""Test staff can update any job."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/jobs/jobs/{test_job.id}/",
|
||||
json={"status": "DONE"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_patch_nonexistent_job(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent job returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/jobs/{fake_id}/",
|
||||
json={"status": "DONE"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_job_forbidden(
|
||||
self, auth_client: AsyncClient, other_job: Job
|
||||
):
|
||||
"""Test regular user cannot update other user's job."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/jobs/{other_job.id}/",
|
||||
json={"status": "CANCELLED"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteJobEndpoint:
|
||||
"""Tests for DELETE /api/jobs/jobs/{job_id}/."""
|
||||
|
||||
async def test_delete_own_job(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test deleting own job."""
|
||||
response = await auth_client.delete(f"/api/jobs/jobs/{test_job.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_job_as_staff(
|
||||
self, staff_client: AsyncClient, test_job: Job
|
||||
):
|
||||
"""Test staff can delete any job."""
|
||||
response = await staff_client.delete(f"/api/jobs/jobs/{test_job.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_job(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent job returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/jobs/jobs/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_job_forbidden(
|
||||
self, auth_client: AsyncClient, other_job: Job
|
||||
):
|
||||
"""Test regular user cannot delete other user's job."""
|
||||
response = await auth_client.delete(f"/api/jobs/jobs/{other_job.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestListEventsEndpoint:
|
||||
"""Tests for GET /api/jobs/events/."""
|
||||
|
||||
async def test_list_events(self, auth_client: AsyncClient, test_event: JobEvent):
|
||||
"""Test listing job events."""
|
||||
response = await auth_client.get("/api/jobs/events/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_events_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing events without auth returns 401."""
|
||||
response = await async_client.get("/api/jobs/events/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateEventEndpoint:
|
||||
"""Tests for POST /api/jobs/events/."""
|
||||
|
||||
async def test_create_event_success(self, auth_client: AsyncClient, test_job: Job):
|
||||
"""Test creating a job event."""
|
||||
response = await auth_client.post(
|
||||
"/api/jobs/events/",
|
||||
json={
|
||||
"job_id": str(test_job.id),
|
||||
"event_type": "progress",
|
||||
"payload": {"percentage": 25},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["event_type"] == "progress"
|
||||
assert data["payload"]["percentage"] == 25
|
||||
|
||||
async def test_create_event_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating event without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/jobs/events/",
|
||||
json={
|
||||
"job_id": str(uuid.uuid4()),
|
||||
"event_type": "test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveEventEndpoint:
|
||||
"""Tests for GET /api/jobs/events/{event_id}/."""
|
||||
|
||||
async def test_retrieve_event(self, auth_client: AsyncClient, test_event: JobEvent):
|
||||
"""Test retrieving a job event."""
|
||||
response = await auth_client.get(f"/api/jobs/events/{test_event.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_event.id)
|
||||
assert data["event_type"] == test_event.event_type
|
||||
|
||||
async def test_retrieve_nonexistent_event(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent event returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/jobs/events/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPatchEventEndpoint:
|
||||
"""Tests for PATCH /api/jobs/events/{event_id}/."""
|
||||
|
||||
async def test_patch_event(self, auth_client: AsyncClient, test_event: JobEvent):
|
||||
"""Test updating a job event."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/events/{test_event.id}/",
|
||||
json={"payload": {"updated": True}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["payload"]["updated"] is True
|
||||
|
||||
async def test_patch_nonexistent_event(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent event returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/jobs/events/{fake_id}/",
|
||||
json={"payload": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteEventEndpoint:
|
||||
"""Tests for DELETE /api/jobs/events/{event_id}/."""
|
||||
|
||||
async def test_delete_event(self, auth_client: AsyncClient, test_event: JobEvent):
|
||||
"""Test deleting a job event."""
|
||||
response = await auth_client.delete(f"/api/jobs/events/{test_event.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_event(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent event returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/jobs/events/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Tests for media management endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.media.models import ArtifactMediaFile, MediaFile
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_media_file(test_db_session: AsyncSession, test_user: User) -> MediaFile:
|
||||
"""Create a test media file owned by test_user."""
|
||||
media_file = MediaFile(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=test_user.id,
|
||||
duration_seconds=120.5,
|
||||
frame_rate=30.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
is_deleted=False,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(media_file)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(media_file)
|
||||
return media_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_media_file(
|
||||
test_db_session: AsyncSession, other_user: User
|
||||
) -> MediaFile:
|
||||
"""Create a media file owned by another user."""
|
||||
media_file = MediaFile(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=other_user.id,
|
||||
duration_seconds=60.0,
|
||||
frame_rate=24.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
is_deleted=False,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(media_file)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(media_file)
|
||||
return media_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_artifact(
|
||||
test_db_session: AsyncSession, test_media_file: MediaFile
|
||||
) -> ArtifactMediaFile:
|
||||
"""Create a test artifact linked to test_media_file."""
|
||||
artifact = ArtifactMediaFile(
|
||||
id=uuid.uuid4(),
|
||||
media_file_id=test_media_file.id,
|
||||
artifact_type="THUMBNAIL",
|
||||
is_deleted=False,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(artifact)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(artifact)
|
||||
return artifact
|
||||
|
||||
|
||||
class TestGetMetaEndpoint:
|
||||
"""Tests for GET /api/media/get_meta/."""
|
||||
|
||||
async def test_get_meta_success(self, auth_client: AsyncClient):
|
||||
"""Test getting media metadata."""
|
||||
with patch(
|
||||
"cpv3.modules.media.router.probe_media",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"streams": [],
|
||||
"format": {"filename": "test.mp4", "duration": "120.5"},
|
||||
},
|
||||
):
|
||||
response = await auth_client.get(
|
||||
"/api/media/get_meta/", params={"file_path": "uploads/test.mp4"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_get_meta_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test getting metadata without auth returns 401."""
|
||||
response = await async_client.get(
|
||||
"/api/media/get_meta/", params={"file_path": "test.mp4"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestSilenceRemoveEndpoint:
|
||||
"""Tests for POST /api/media/silence_remove."""
|
||||
|
||||
async def test_silence_remove_success(self, auth_client: AsyncClient, mock_storage):
|
||||
"""Test silence removal returns file info."""
|
||||
with patch(
|
||||
"cpv3.modules.media.router.remove_silence",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_remove:
|
||||
mock_remove.return_value = mock_storage.get_file_info.return_value
|
||||
|
||||
response = await auth_client.post(
|
||||
"/api/media/silence_remove",
|
||||
json={"file_path": "uploads/test.mp4", "folder": "processed"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "file_path" in data
|
||||
|
||||
async def test_silence_remove_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test silence removal without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/media/silence_remove",
|
||||
json={"file_path": "test.mp4"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestConvertEndpoint:
|
||||
"""Tests for POST /api/media/convert."""
|
||||
|
||||
async def test_convert_success(self, auth_client: AsyncClient, mock_storage):
|
||||
"""Test media conversion returns file info."""
|
||||
with patch(
|
||||
"cpv3.modules.media.router.convert_to_mp4",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_convert:
|
||||
mock_convert.return_value = mock_storage.get_file_info.return_value
|
||||
|
||||
response = await auth_client.post(
|
||||
"/api/media/convert",
|
||||
json={"file_path": "uploads/test.mov", "folder": "converted"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "file_path" in data
|
||||
|
||||
async def test_convert_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test conversion without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/media/convert",
|
||||
json={"file_path": "test.mov"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestListMediaFilesEndpoint:
|
||||
"""Tests for GET /api/media/mediafiles/."""
|
||||
|
||||
async def test_list_mediafiles(
|
||||
self, auth_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test listing media files."""
|
||||
response = await auth_client.get("/api/media/mediafiles/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_mediafiles_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing media files without auth returns 401."""
|
||||
response = await async_client.get("/api/media/mediafiles/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateMediaFileEndpoint:
|
||||
"""Tests for POST /api/media/mediafiles/."""
|
||||
|
||||
async def test_create_mediafile_success(self, auth_client: AsyncClient):
|
||||
"""Test creating a media file entry."""
|
||||
response = await auth_client.post(
|
||||
"/api/media/mediafiles/",
|
||||
json={
|
||||
"duration_seconds": 180.0,
|
||||
"frame_rate": 60.0,
|
||||
"width": 3840,
|
||||
"height": 2160,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["duration_seconds"] == 180.0
|
||||
assert data["width"] == 3840
|
||||
|
||||
async def test_create_mediafile_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating media file without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/media/mediafiles/",
|
||||
json={"duration_seconds": 60.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveMediaFileEndpoint:
|
||||
"""Tests for GET /api/media/mediafiles/{media_file_id}/."""
|
||||
|
||||
async def test_retrieve_own_mediafile(
|
||||
self, auth_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test retrieving own media file."""
|
||||
response = await auth_client.get(f"/api/media/mediafiles/{test_media_file.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_media_file.id)
|
||||
|
||||
async def test_retrieve_other_mediafile_as_staff(
|
||||
self, staff_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test staff can retrieve any media file."""
|
||||
response = await staff_client.get(
|
||||
f"/api/media/mediafiles/{test_media_file.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_retrieve_nonexistent_mediafile(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent media file returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/media/mediafiles/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_retrieve_other_mediafile_forbidden(
|
||||
self, auth_client: AsyncClient, other_media_file: MediaFile
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's media file."""
|
||||
response = await auth_client.get(
|
||||
f"/api/media/mediafiles/{other_media_file.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPatchMediaFileEndpoint:
|
||||
"""Tests for PATCH /api/media/mediafiles/{media_file_id}/."""
|
||||
|
||||
async def test_patch_own_mediafile(
|
||||
self, auth_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test updating own media file."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/media/mediafiles/{test_media_file.id}/",
|
||||
json={"notes": "Updated notes"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["notes"] == "Updated notes"
|
||||
|
||||
async def test_patch_other_mediafile_as_staff(
|
||||
self, staff_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test staff can update any media file."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/media/mediafiles/{test_media_file.id}/",
|
||||
json={"notes": "Staff updated"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_patch_nonexistent_mediafile(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent media file returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/media/mediafiles/{fake_id}/",
|
||||
json={"notes": "test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_mediafile_forbidden(
|
||||
self, auth_client: AsyncClient, other_media_file: MediaFile
|
||||
):
|
||||
"""Test regular user cannot update other user's media file."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/media/mediafiles/{other_media_file.id}/",
|
||||
json={"notes": "hacked"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteMediaFileEndpoint:
|
||||
"""Tests for DELETE /api/media/mediafiles/{media_file_id}/."""
|
||||
|
||||
async def test_delete_own_mediafile(
|
||||
self, auth_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test deleting own media file."""
|
||||
response = await auth_client.delete(
|
||||
f"/api/media/mediafiles/{test_media_file.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_mediafile_as_staff(
|
||||
self, staff_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test staff can delete any media file."""
|
||||
response = await staff_client.delete(
|
||||
f"/api/media/mediafiles/{test_media_file.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_mediafile(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent media file returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/media/mediafiles/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_mediafile_forbidden(
|
||||
self, auth_client: AsyncClient, other_media_file: MediaFile
|
||||
):
|
||||
"""Test regular user cannot delete other user's media file."""
|
||||
response = await auth_client.delete(
|
||||
f"/api/media/mediafiles/{other_media_file.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestListArtifactsEndpoint:
|
||||
"""Tests for GET /api/media/artifacts/."""
|
||||
|
||||
async def test_list_artifacts(
|
||||
self, auth_client: AsyncClient, test_artifact: ArtifactMediaFile
|
||||
):
|
||||
"""Test listing artifacts."""
|
||||
response = await auth_client.get("/api/media/artifacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_artifacts_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing artifacts without auth returns 401."""
|
||||
response = await async_client.get("/api/media/artifacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateArtifactEndpoint:
|
||||
"""Tests for POST /api/media/artifacts/."""
|
||||
|
||||
async def test_create_artifact_success(
|
||||
self, auth_client: AsyncClient, test_media_file: MediaFile
|
||||
):
|
||||
"""Test creating an artifact."""
|
||||
response = await auth_client.post(
|
||||
"/api/media/artifacts/",
|
||||
json={
|
||||
"media_file_id": str(test_media_file.id),
|
||||
"artifact_type": "AUDIO_PROXY",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["artifact_type"] == "AUDIO_PROXY"
|
||||
|
||||
async def test_create_artifact_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating artifact without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/media/artifacts/",
|
||||
json={
|
||||
"media_file_id": str(uuid.uuid4()),
|
||||
"artifact_type": "THUMBNAIL",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveArtifactEndpoint:
|
||||
"""Tests for GET /api/media/artifacts/{artifact_id}/."""
|
||||
|
||||
async def test_retrieve_artifact(
|
||||
self, auth_client: AsyncClient, test_artifact: ArtifactMediaFile
|
||||
):
|
||||
"""Test retrieving an artifact."""
|
||||
response = await auth_client.get(f"/api/media/artifacts/{test_artifact.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_artifact.id)
|
||||
|
||||
async def test_retrieve_nonexistent_artifact(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent artifact returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/media/artifacts/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPatchArtifactEndpoint:
|
||||
"""Tests for PATCH /api/media/artifacts/{artifact_id}/."""
|
||||
|
||||
async def test_patch_artifact(
|
||||
self, auth_client: AsyncClient, test_artifact: ArtifactMediaFile
|
||||
):
|
||||
"""Test updating an artifact."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/media/artifacts/{test_artifact.id}/",
|
||||
json={"is_deleted": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_deleted"] is True
|
||||
|
||||
async def test_patch_nonexistent_artifact(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent artifact returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/media/artifacts/{fake_id}/",
|
||||
json={"is_deleted": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteArtifactEndpoint:
|
||||
"""Tests for DELETE /api/media/artifacts/{artifact_id}/."""
|
||||
|
||||
async def test_delete_artifact(
|
||||
self, auth_client: AsyncClient, test_artifact: ArtifactMediaFile
|
||||
):
|
||||
"""Test deleting an artifact."""
|
||||
response = await auth_client.delete(f"/api/media/artifacts/{test_artifact.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_artifact(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent artifact returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/media/artifacts/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Tests for project management endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.projects.models import Project
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_project(test_db_session: AsyncSession, test_user: User) -> Project:
|
||||
"""Create a test project owned by test_user."""
|
||||
project = Project(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=test_user.id,
|
||||
name="Test Project",
|
||||
description="A test project",
|
||||
language="en",
|
||||
status="DRAFT",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(project)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_project(test_db_session: AsyncSession, other_user: User) -> Project:
|
||||
"""Create a project owned by another user."""
|
||||
project = Project(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=other_user.id,
|
||||
name="Other Project",
|
||||
description="Another user's project",
|
||||
language="en",
|
||||
status="DRAFT",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(project)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
class TestListProjectsEndpoint:
|
||||
"""Tests for GET /api/projects/."""
|
||||
|
||||
async def test_list_projects_authenticated(
|
||||
self, auth_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test listing projects as authenticated user."""
|
||||
response = await auth_client.get("/api/projects/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_projects_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing projects without auth returns 401."""
|
||||
response = await async_client.get("/api/projects/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateProjectEndpoint:
|
||||
"""Tests for POST /api/projects/."""
|
||||
|
||||
async def test_create_project_success(self, auth_client: AsyncClient):
|
||||
"""Test creating a new project."""
|
||||
response = await auth_client.post(
|
||||
"/api/projects/",
|
||||
json={
|
||||
"name": "New Project",
|
||||
"description": "A new project",
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "New Project"
|
||||
assert data["description"] == "A new project"
|
||||
assert data["language"] == "en"
|
||||
assert data["status"] == "DRAFT"
|
||||
|
||||
async def test_create_project_minimal(self, auth_client: AsyncClient):
|
||||
"""Test creating a project with minimal fields."""
|
||||
response = await auth_client.post(
|
||||
"/api/projects/",
|
||||
json={"name": "Minimal Project"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Minimal Project"
|
||||
|
||||
async def test_create_project_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating project without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/projects/",
|
||||
json={"name": "Unauthorized Project"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveProjectEndpoint:
|
||||
"""Tests for GET /api/projects/{project_id}/."""
|
||||
|
||||
async def test_retrieve_own_project(
|
||||
self, auth_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test retrieving own project."""
|
||||
response = await auth_client.get(f"/api/projects/{test_project.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_project.id)
|
||||
assert data["name"] == test_project.name
|
||||
|
||||
async def test_retrieve_other_project_as_staff(
|
||||
self, staff_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test staff can retrieve any project."""
|
||||
response = await staff_client.get(f"/api/projects/{test_project.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_project.id)
|
||||
|
||||
async def test_retrieve_nonexistent_project(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent project returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/projects/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Not found"
|
||||
|
||||
async def test_retrieve_other_project_forbidden(
|
||||
self, auth_client: AsyncClient, other_project: Project
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's project."""
|
||||
response = await auth_client.get(f"/api/projects/{other_project.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Forbidden"
|
||||
|
||||
|
||||
class TestPatchProjectEndpoint:
|
||||
"""Tests for PATCH /api/projects/{project_id}/."""
|
||||
|
||||
async def test_patch_own_project(
|
||||
self, auth_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test updating own project."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/projects/{test_project.id}/",
|
||||
json={"name": "Updated Project", "description": "Updated description"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Project"
|
||||
assert data["description"] == "Updated description"
|
||||
|
||||
async def test_patch_project_status(
|
||||
self, auth_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test updating project status."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/projects/{test_project.id}/",
|
||||
json={"status": "PROCESSING"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "PROCESSING"
|
||||
|
||||
async def test_patch_other_project_as_staff(
|
||||
self, staff_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test staff can update any project."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/projects/{test_project.id}/",
|
||||
json={"name": "Staff Updated"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Staff Updated"
|
||||
|
||||
async def test_patch_nonexistent_project(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent project returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/projects/{fake_id}/",
|
||||
json={"name": "Test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_project_forbidden(
|
||||
self, auth_client: AsyncClient, other_project: Project
|
||||
):
|
||||
"""Test regular user cannot update other user's project."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/projects/{other_project.id}/",
|
||||
json={"name": "Hacked"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteProjectEndpoint:
|
||||
"""Tests for DELETE /api/projects/{project_id}/."""
|
||||
|
||||
async def test_delete_own_project(
|
||||
self, auth_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test deleting (deactivating) own project."""
|
||||
response = await auth_client.delete(f"/api/projects/{test_project.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_project_as_staff(
|
||||
self, staff_client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Test staff can delete any project."""
|
||||
response = await staff_client.delete(f"/api/projects/{test_project.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_project(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent project returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/projects/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_project_forbidden(
|
||||
self, auth_client: AsyncClient, other_project: Project
|
||||
):
|
||||
"""Test regular user cannot delete other user's project."""
|
||||
response = await auth_client.delete(f"/api/projects/{other_project.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Tests for system endpoints (health check).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestSystemEndpoints:
|
||||
"""Tests for GET /api/ping/."""
|
||||
|
||||
async def test_ping_returns_ok(self, async_client: AsyncClient):
|
||||
"""Test health check endpoint returns ok status."""
|
||||
response = await async_client.get("/api/ping/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
async def test_ping_no_auth_required(self, async_client: AsyncClient):
|
||||
"""Test health check endpoint works without authentication."""
|
||||
# async_client has no auth header set
|
||||
response = await async_client.get("/api/ping/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Tests for transcription endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.files.models import File
|
||||
from cpv3.modules.transcription.models import Transcription
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def source_file(test_db_session: AsyncSession, test_user: User) -> File:
|
||||
"""Create a source file for transcription."""
|
||||
file = File(
|
||||
id=uuid.uuid4(),
|
||||
owner_id=test_user.id,
|
||||
original_filename="audio.mp3",
|
||||
path="uploads/audio.mp3",
|
||||
storage_backend="LOCAL",
|
||||
mime_type="audio/mpeg",
|
||||
size_bytes=5000000,
|
||||
is_uploaded=True,
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(file)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(file)
|
||||
return file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_transcription(
|
||||
test_db_session: AsyncSession, source_file: File
|
||||
) -> Transcription:
|
||||
"""Create a test transcription."""
|
||||
transcription = Transcription(
|
||||
id=uuid.uuid4(),
|
||||
source_file_id=source_file.id,
|
||||
engine="LOCAL_WHISPER",
|
||||
language="en",
|
||||
document={"segments": []},
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(transcription)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(transcription)
|
||||
return transcription
|
||||
|
||||
|
||||
class TestListTranscriptionsEndpoint:
|
||||
"""Tests for GET /api/transcribe/transcriptions/."""
|
||||
|
||||
async def test_list_transcriptions(
|
||||
self, auth_client: AsyncClient, test_transcription: Transcription
|
||||
):
|
||||
"""Test listing transcriptions."""
|
||||
response = await auth_client.get("/api/transcribe/transcriptions/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_transcriptions_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing transcriptions without auth returns 401."""
|
||||
response = await async_client.get("/api/transcribe/transcriptions/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateTranscriptionEndpoint:
|
||||
"""Tests for POST /api/transcribe/transcriptions/."""
|
||||
|
||||
async def test_create_transcription_success(
|
||||
self, auth_client: AsyncClient, source_file: File
|
||||
):
|
||||
"""Test creating a transcription entry."""
|
||||
response = await auth_client.post(
|
||||
"/api/transcribe/transcriptions/",
|
||||
json={
|
||||
"source_file_id": str(source_file.id),
|
||||
"engine": "LOCAL_WHISPER",
|
||||
"language": "en",
|
||||
"document": {
|
||||
"segments": [
|
||||
{
|
||||
"text": "Hello world",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 2.0},
|
||||
"lines": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["engine"] == "LOCAL_WHISPER"
|
||||
assert data["language"] == "en"
|
||||
|
||||
async def test_create_transcription_unauthenticated(
|
||||
self, async_client: AsyncClient
|
||||
):
|
||||
"""Test creating transcription without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/transcribe/transcriptions/",
|
||||
json={
|
||||
"source_file_id": str(uuid.uuid4()),
|
||||
"document": {"segments": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveTranscriptionEndpoint:
|
||||
"""Tests for GET /api/transcribe/transcriptions/{transcription_id}/."""
|
||||
|
||||
async def test_retrieve_transcription(
|
||||
self, auth_client: AsyncClient, test_transcription: Transcription
|
||||
):
|
||||
"""Test retrieving a transcription."""
|
||||
response = await auth_client.get(
|
||||
f"/api/transcribe/transcriptions/{test_transcription.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_transcription.id)
|
||||
assert data["engine"] == test_transcription.engine
|
||||
|
||||
async def test_retrieve_nonexistent_transcription(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent transcription returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/transcribe/transcriptions/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPatchTranscriptionEndpoint:
|
||||
"""Tests for PATCH /api/transcribe/transcriptions/{transcription_id}/."""
|
||||
|
||||
async def test_patch_transcription(
|
||||
self, auth_client: AsyncClient, test_transcription: Transcription
|
||||
):
|
||||
"""Test updating a transcription."""
|
||||
updated_document = {
|
||||
"segments": [
|
||||
{
|
||||
"text": "Updated text",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 3.0},
|
||||
"lines": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = await auth_client.patch(
|
||||
f"/api/transcribe/transcriptions/{test_transcription.id}/",
|
||||
json={"document": updated_document},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["document"]["segments"][0]["text"] == "Updated text"
|
||||
|
||||
async def test_patch_nonexistent_transcription(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent transcription returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/transcribe/transcriptions/{fake_id}/",
|
||||
json={"document": {"segments": []}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteTranscriptionEndpoint:
|
||||
"""Tests for DELETE /api/transcribe/transcriptions/{transcription_id}/."""
|
||||
|
||||
async def test_delete_transcription(
|
||||
self, auth_client: AsyncClient, test_transcription: Transcription
|
||||
):
|
||||
"""Test deleting a transcription."""
|
||||
response = await auth_client.delete(
|
||||
f"/api/transcribe/transcriptions/{test_transcription.id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_transcription(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent transcription returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(
|
||||
f"/api/transcribe/transcriptions/{fake_id}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestWhisperTranscribeEndpoint:
|
||||
"""Tests for POST /api/transcribe/whisper/."""
|
||||
|
||||
async def test_whisper_transcribe_success(self, auth_client: AsyncClient):
|
||||
"""Test Whisper transcription endpoint."""
|
||||
mock_result = {
|
||||
"segments": [
|
||||
{
|
||||
"text": "Hello from Whisper",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 2.5},
|
||||
"lines": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cpv3.modules.transcription.router.transcribe_with_whisper",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
response = await auth_client.post(
|
||||
"/api/transcribe/whisper/",
|
||||
json={
|
||||
"file_path": "uploads/audio.mp3",
|
||||
"model_name": "tiny",
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_whisper_transcribe_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test Whisper transcription without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/transcribe/whisper/",
|
||||
json={"file_path": "test.mp3"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestGoogleSpeechTranscribeEndpoint:
|
||||
"""Tests for POST /api/transcribe/google-speech/."""
|
||||
|
||||
async def test_google_speech_transcribe_success(self, auth_client: AsyncClient):
|
||||
"""Test Google Speech transcription endpoint."""
|
||||
mock_result = {
|
||||
"segments": [
|
||||
{
|
||||
"text": "Hello from Google",
|
||||
"semantic_tags": [],
|
||||
"structure_tags": [],
|
||||
"time": {"start": 0.0, "end": 2.0},
|
||||
"lines": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cpv3.modules.transcription.router.transcribe_with_google_speech",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
response = await auth_client.post(
|
||||
"/api/transcribe/google-speech/",
|
||||
json={
|
||||
"file_path": "uploads/audio.mp3",
|
||||
"language_codes": ["en-US"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_google_speech_transcribe_unauthenticated(
|
||||
self, async_client: AsyncClient
|
||||
):
|
||||
"""Test Google Speech transcription without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/transcribe/google-speech/",
|
||||
json={"file_path": "test.mp3"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Tests for user management endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
class TestListUsersEndpoint:
|
||||
"""Tests for GET /api/users/."""
|
||||
|
||||
async def test_list_users_authenticated(
|
||||
self, auth_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test listing users as authenticated user."""
|
||||
response = await auth_client.get("/api/users/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_users_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing users without auth returns 401."""
|
||||
response = await async_client.get("/api/users/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateUserEndpoint:
|
||||
"""Tests for POST /api/users/."""
|
||||
|
||||
async def test_create_user_as_staff(self, staff_client: AsyncClient):
|
||||
"""Test staff can create a new user."""
|
||||
response = await staff_client.post(
|
||||
"/api/users/",
|
||||
json={
|
||||
"username": "newcreateduser",
|
||||
"email": "newcreated@example.com",
|
||||
"password": "password123",
|
||||
"first_name": "New",
|
||||
"last_name": "Created",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username"] == "newcreateduser"
|
||||
assert data["email"] == "newcreated@example.com"
|
||||
|
||||
async def test_create_user_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating user without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/users/",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestMeEndpoint:
|
||||
"""Tests for GET /api/users/me/."""
|
||||
|
||||
async def test_me_returns_current_user(
|
||||
self, auth_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test getting current user info."""
|
||||
response = await auth_client.get("/api/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == test_user.username
|
||||
assert data["email"] == test_user.email
|
||||
assert data["id"] == str(test_user.id)
|
||||
|
||||
async def test_me_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test getting current user without auth returns 401."""
|
||||
response = await async_client.get("/api/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveUserEndpoint:
|
||||
"""Tests for GET /api/users/{user_id}/."""
|
||||
|
||||
async def test_retrieve_own_user(self, auth_client: AsyncClient, test_user: User):
|
||||
"""Test user can retrieve their own info."""
|
||||
response = await auth_client.get(f"/api/users/{test_user.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_user.id)
|
||||
assert data["username"] == test_user.username
|
||||
|
||||
async def test_retrieve_other_user_as_staff(
|
||||
self, staff_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test staff can retrieve other user's info."""
|
||||
response = await staff_client.get(f"/api/users/{test_user.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_user.id)
|
||||
|
||||
async def test_retrieve_nonexistent_user(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent user returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/users/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Not found"
|
||||
|
||||
async def test_retrieve_other_user_forbidden(
|
||||
self, auth_client: AsyncClient, other_user: User
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's info."""
|
||||
response = await auth_client.get(f"/api/users/{other_user.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Forbidden"
|
||||
|
||||
|
||||
class TestPatchUserEndpoint:
|
||||
"""Tests for PATCH /api/users/{user_id}/."""
|
||||
|
||||
async def test_patch_own_user(self, auth_client: AsyncClient, test_user: User):
|
||||
"""Test user can update their own info."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/users/{test_user.id}/",
|
||||
json={"first_name": "Updated", "last_name": "Name"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["first_name"] == "Updated"
|
||||
assert data["last_name"] == "Name"
|
||||
|
||||
async def test_patch_other_user_as_staff(
|
||||
self, staff_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test staff can update other user's info."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/users/{test_user.id}/",
|
||||
json={"first_name": "StaffUpdated"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["first_name"] == "StaffUpdated"
|
||||
|
||||
async def test_patch_nonexistent_user(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent user returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/users/{fake_id}/",
|
||||
json={"first_name": "Test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_user_forbidden(
|
||||
self, auth_client: AsyncClient, other_user: User
|
||||
):
|
||||
"""Test regular user cannot update other user's info."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/users/{other_user.id}/",
|
||||
json={"first_name": "Hacked"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteUserEndpoint:
|
||||
"""Tests for DELETE /api/users/{user_id}/."""
|
||||
|
||||
async def test_delete_own_user(self, auth_client: AsyncClient, test_user: User):
|
||||
"""Test user can delete (deactivate) their own account."""
|
||||
response = await auth_client.delete(f"/api/users/{test_user.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_user_as_staff(
|
||||
self, staff_client: AsyncClient, test_user: User
|
||||
):
|
||||
"""Test staff can delete other user's account."""
|
||||
response = await staff_client.delete(f"/api/users/{test_user.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_user(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent user returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/users/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_user_forbidden(
|
||||
self, auth_client: AsyncClient, other_user: User
|
||||
):
|
||||
"""Test regular user cannot delete other user's account."""
|
||||
response = await auth_client.delete(f"/api/users/{other_user.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Tests for webhooks endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.webhooks.models import Webhook
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_webhook(test_db_session: AsyncSession, test_user: User) -> Webhook:
|
||||
"""Create a test webhook owned by test_user."""
|
||||
webhook = Webhook(
|
||||
id=uuid.uuid4(),
|
||||
user_id=test_user.id,
|
||||
event="job.completed",
|
||||
url="https://example.com/webhook",
|
||||
secret="test-secret-123",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(webhook)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_webhook(test_db_session: AsyncSession, other_user: User) -> Webhook:
|
||||
"""Create a webhook owned by another user."""
|
||||
webhook = Webhook(
|
||||
id=uuid.uuid4(),
|
||||
user_id=other_user.id,
|
||||
event="job.failed",
|
||||
url="https://other.com/webhook",
|
||||
secret="other-secret-456",
|
||||
is_active=True,
|
||||
)
|
||||
test_db_session.add(webhook)
|
||||
await test_db_session.commit()
|
||||
await test_db_session.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
class TestListWebhooksEndpoint:
|
||||
"""Tests for GET /api/webhooks/."""
|
||||
|
||||
async def test_list_webhooks(self, auth_client: AsyncClient, test_webhook: Webhook):
|
||||
"""Test listing webhooks."""
|
||||
response = await auth_client.get("/api/webhooks/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_list_webhooks_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test listing webhooks without auth returns 401."""
|
||||
response = await async_client.get("/api/webhooks/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCreateWebhookEndpoint:
|
||||
"""Tests for POST /api/webhooks/."""
|
||||
|
||||
async def test_create_webhook_success(self, auth_client: AsyncClient):
|
||||
"""Test creating a webhook."""
|
||||
response = await auth_client.post(
|
||||
"/api/webhooks/",
|
||||
json={
|
||||
"event": "transcription.completed",
|
||||
"url": "https://myapp.com/webhook",
|
||||
"secret": "my-webhook-secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["event"] == "transcription.completed"
|
||||
assert data["url"] == "https://myapp.com/webhook"
|
||||
|
||||
async def test_create_webhook_minimal(self, auth_client: AsyncClient):
|
||||
"""Test creating a webhook with minimal fields."""
|
||||
response = await auth_client.post(
|
||||
"/api/webhooks/",
|
||||
json={"url": "https://minimal.com/hook"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["url"] == "https://minimal.com/hook"
|
||||
|
||||
async def test_create_webhook_unauthenticated(self, async_client: AsyncClient):
|
||||
"""Test creating webhook without auth returns 401."""
|
||||
response = await async_client.post(
|
||||
"/api/webhooks/",
|
||||
json={"url": "https://test.com/hook"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRetrieveWebhookEndpoint:
|
||||
"""Tests for GET /api/webhooks/{webhook_id}/."""
|
||||
|
||||
async def test_retrieve_own_webhook(
|
||||
self, auth_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test retrieving own webhook."""
|
||||
response = await auth_client.get(f"/api/webhooks/{test_webhook.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_webhook.id)
|
||||
assert data["url"] == test_webhook.url
|
||||
|
||||
async def test_retrieve_other_webhook_as_staff(
|
||||
self, staff_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test staff can retrieve any webhook."""
|
||||
response = await staff_client.get(f"/api/webhooks/{test_webhook.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_retrieve_nonexistent_webhook(self, auth_client: AsyncClient):
|
||||
"""Test retrieving nonexistent webhook returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.get(f"/api/webhooks/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Not found"
|
||||
|
||||
async def test_retrieve_other_webhook_forbidden(
|
||||
self, auth_client: AsyncClient, other_webhook: Webhook
|
||||
):
|
||||
"""Test regular user cannot retrieve other user's webhook."""
|
||||
response = await auth_client.get(f"/api/webhooks/{other_webhook.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Forbidden"
|
||||
|
||||
|
||||
class TestPatchWebhookEndpoint:
|
||||
"""Tests for PATCH /api/webhooks/{webhook_id}/."""
|
||||
|
||||
async def test_patch_own_webhook(
|
||||
self, auth_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test updating own webhook."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/webhooks/{test_webhook.id}/",
|
||||
json={
|
||||
"url": "https://updated.com/webhook",
|
||||
"event": "job.started",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["url"] == "https://updated.com/webhook"
|
||||
assert data["event"] == "job.started"
|
||||
|
||||
async def test_patch_webhook_deactivate(
|
||||
self, auth_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test deactivating a webhook."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/webhooks/{test_webhook.id}/",
|
||||
json={"is_active": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_active"] is False
|
||||
|
||||
async def test_patch_other_webhook_as_staff(
|
||||
self, staff_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test staff can update any webhook."""
|
||||
response = await staff_client.patch(
|
||||
f"/api/webhooks/{test_webhook.id}/",
|
||||
json={"event": "staff.updated"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_patch_nonexistent_webhook(self, auth_client: AsyncClient):
|
||||
"""Test patching nonexistent webhook returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.patch(
|
||||
f"/api/webhooks/{fake_id}/",
|
||||
json={"url": "https://test.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_patch_other_webhook_forbidden(
|
||||
self, auth_client: AsyncClient, other_webhook: Webhook
|
||||
):
|
||||
"""Test regular user cannot update other user's webhook."""
|
||||
response = await auth_client.patch(
|
||||
f"/api/webhooks/{other_webhook.id}/",
|
||||
json={"url": "https://hacked.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDeleteWebhookEndpoint:
|
||||
"""Tests for DELETE /api/webhooks/{webhook_id}/."""
|
||||
|
||||
async def test_delete_own_webhook(
|
||||
self, auth_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test deleting own webhook."""
|
||||
response = await auth_client.delete(f"/api/webhooks/{test_webhook.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_other_webhook_as_staff(
|
||||
self, staff_client: AsyncClient, test_webhook: Webhook
|
||||
):
|
||||
"""Test staff can delete any webhook."""
|
||||
response = await staff_client.delete(f"/api/webhooks/{test_webhook.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_delete_nonexistent_webhook(self, auth_client: AsyncClient):
|
||||
"""Test deleting nonexistent webhook returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await auth_client.delete(f"/api/webhooks/{fake_id}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_delete_other_webhook_forbidden(
|
||||
self, auth_client: AsyncClient, other_webhook: Webhook
|
||||
):
|
||||
"""Test regular user cannot delete other user's webhook."""
|
||||
response = await auth_client.delete(f"/api/webhooks/{other_webhook.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Unit tests for S3 storage backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from cpv3.infrastructure.storage.s3 import S3Config, S3StorageBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_config() -> S3Config:
|
||||
"""Create a test S3 configuration."""
|
||||
return S3Config(
|
||||
access_key="test-access-key",
|
||||
secret_key="test-secret-key",
|
||||
bucket_name="test-bucket",
|
||||
endpoint_url_internal="http://localhost:9000",
|
||||
endpoint_url_public="http://localhost:9000",
|
||||
presign_expires_seconds=3600,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boto3_session():
|
||||
"""Mock boto3 session and clients."""
|
||||
with patch("cpv3.infrastructure.storage.s3.boto3.session.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_presign_client = MagicMock()
|
||||
|
||||
# Track which client is being created
|
||||
clients = [mock_client, mock_presign_client]
|
||||
client_index = [0]
|
||||
|
||||
def create_client(*args, **kwargs):
|
||||
idx = client_index[0]
|
||||
client_index[0] += 1
|
||||
return clients[idx % 2]
|
||||
|
||||
mock_session.return_value.client.side_effect = create_client
|
||||
|
||||
yield {
|
||||
"session": mock_session,
|
||||
"client": mock_client,
|
||||
"presign_client": mock_presign_client,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_backend(s3_config: S3Config, mock_boto3_session) -> S3StorageBackend:
|
||||
"""Create an S3StorageBackend with mocked boto3."""
|
||||
return S3StorageBackend(s3_config)
|
||||
|
||||
|
||||
class TestS3Config:
|
||||
"""Tests for S3Config dataclass."""
|
||||
|
||||
def test_config_creation(self):
|
||||
"""Test creating S3 config with all parameters."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal="http://internal:9000",
|
||||
endpoint_url_public="http://public:9000",
|
||||
presign_expires_seconds=7200,
|
||||
)
|
||||
|
||||
assert config.access_key == "key"
|
||||
assert config.secret_key == "secret"
|
||||
assert config.bucket_name == "bucket"
|
||||
assert config.endpoint_url_internal == "http://internal:9000"
|
||||
assert config.endpoint_url_public == "http://public:9000"
|
||||
assert config.presign_expires_seconds == 7200
|
||||
|
||||
def test_config_default_presign_expires(self):
|
||||
"""Test default presign expiration time."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal=None,
|
||||
endpoint_url_public=None,
|
||||
)
|
||||
|
||||
assert config.presign_expires_seconds == 3600
|
||||
|
||||
def test_config_is_frozen(self):
|
||||
"""Test that config is immutable."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal=None,
|
||||
endpoint_url_public=None,
|
||||
)
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
config.access_key = "new_key" # type: ignore
|
||||
|
||||
|
||||
class TestS3StorageBackendInit:
|
||||
"""Tests for S3StorageBackend initialization."""
|
||||
|
||||
def test_init_creates_two_clients(self, s3_config: S3Config, mock_boto3_session):
|
||||
"""Test that initialization creates both internal and presign clients."""
|
||||
S3StorageBackend(s3_config)
|
||||
|
||||
# Should create two clients
|
||||
assert mock_boto3_session["session"].return_value.client.call_count == 2
|
||||
|
||||
def test_init_uses_correct_endpoints(self, mock_boto3_session):
|
||||
"""Test that clients use correct endpoints."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal="http://internal:9000",
|
||||
endpoint_url_public="http://public:9000",
|
||||
)
|
||||
|
||||
S3StorageBackend(config)
|
||||
|
||||
calls = mock_boto3_session["session"].return_value.client.call_args_list
|
||||
|
||||
# First call should use internal endpoint
|
||||
assert calls[0][1]["endpoint_url"] == "http://internal:9000"
|
||||
# Second call should use public endpoint
|
||||
assert calls[1][1]["endpoint_url"] == "http://public:9000"
|
||||
|
||||
def test_init_uses_internal_for_presign_when_no_public(self, mock_boto3_session):
|
||||
"""Test that presign client uses internal endpoint when public is not set."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal="http://internal:9000",
|
||||
endpoint_url_public=None,
|
||||
)
|
||||
|
||||
S3StorageBackend(config)
|
||||
|
||||
calls = mock_boto3_session["session"].return_value.client.call_args_list
|
||||
|
||||
# Both should use internal endpoint
|
||||
assert calls[0][1]["endpoint_url"] == "http://internal:9000"
|
||||
assert calls[1][1]["endpoint_url"] == "http://internal:9000"
|
||||
|
||||
|
||||
class TestEnsureBucket:
|
||||
"""Tests for ensure_bucket method."""
|
||||
|
||||
def test_ensure_bucket_creates_bucket_if_not_exists(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that bucket is created if it doesn't exist."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
|
||||
# Simulate bucket not found
|
||||
error_response = {"Error": {"Code": "404"}}
|
||||
mock_client.head_bucket.side_effect = ClientError(error_response, "HeadBucket")
|
||||
|
||||
s3_backend.ensure_bucket()
|
||||
|
||||
mock_client.create_bucket.assert_called_once_with(Bucket="test-bucket")
|
||||
|
||||
def test_ensure_bucket_creates_bucket_no_such_bucket_error(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that bucket is created on NoSuchBucket error."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
|
||||
error_response = {"Error": {"Code": "NoSuchBucket"}}
|
||||
mock_client.head_bucket.side_effect = ClientError(error_response, "HeadBucket")
|
||||
|
||||
s3_backend.ensure_bucket()
|
||||
|
||||
mock_client.create_bucket.assert_called_once_with(Bucket="test-bucket")
|
||||
|
||||
def test_ensure_bucket_does_not_create_if_exists(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that bucket is not created if it already exists."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
s3_backend.ensure_bucket()
|
||||
|
||||
mock_client.create_bucket.assert_not_called()
|
||||
|
||||
def test_ensure_bucket_caches_result(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that bucket check is cached after first call."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
s3_backend.ensure_bucket()
|
||||
s3_backend.ensure_bucket()
|
||||
s3_backend.ensure_bucket()
|
||||
|
||||
# Should only check once
|
||||
assert mock_client.head_bucket.call_count == 1
|
||||
|
||||
def test_ensure_bucket_raises_on_other_errors(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that other errors are raised."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
|
||||
error_response = {"Error": {"Code": "AccessDenied"}}
|
||||
mock_client.head_bucket.side_effect = ClientError(error_response, "HeadBucket")
|
||||
|
||||
with pytest.raises(ClientError):
|
||||
s3_backend.ensure_bucket()
|
||||
|
||||
|
||||
class TestUploadFileobj:
|
||||
"""Tests for upload_fileobj method."""
|
||||
|
||||
def test_upload_fileobj_with_content_type(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test uploading a file with content type."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
s3_backend.upload_fileobj("test/key.txt", fileobj, content_type="text/plain")
|
||||
|
||||
mock_client.upload_fileobj.assert_called_once()
|
||||
call_kwargs = mock_client.upload_fileobj.call_args[1]
|
||||
|
||||
assert call_kwargs["Bucket"] == "test-bucket"
|
||||
assert call_kwargs["Key"] == "test/key.txt"
|
||||
assert call_kwargs["ExtraArgs"] == {"ContentType": "text/plain"}
|
||||
|
||||
def test_upload_fileobj_without_content_type(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test uploading a file without content type."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
s3_backend.upload_fileobj("test/key.txt", fileobj, content_type=None)
|
||||
|
||||
call_kwargs = mock_client.upload_fileobj.call_args[1]
|
||||
assert call_kwargs["ExtraArgs"] is None
|
||||
|
||||
def test_upload_fileobj_ensures_bucket(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that upload_fileobj calls ensure_bucket."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
s3_backend.upload_fileobj("test/key.txt", fileobj, content_type=None)
|
||||
|
||||
mock_client.head_bucket.assert_called_once()
|
||||
|
||||
|
||||
class TestDownloadFileobj:
|
||||
"""Tests for download_fileobj method."""
|
||||
|
||||
def test_download_fileobj(self, s3_backend: S3StorageBackend, mock_boto3_session):
|
||||
"""Test downloading a file."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
fileobj = io.BytesIO()
|
||||
s3_backend.download_fileobj("test/key.txt", fileobj)
|
||||
|
||||
mock_client.download_fileobj.assert_called_once_with(
|
||||
"test-bucket", "test/key.txt", fileobj
|
||||
)
|
||||
|
||||
def test_download_fileobj_ensures_bucket(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that download_fileobj calls ensure_bucket."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
fileobj = io.BytesIO()
|
||||
s3_backend.download_fileobj("test/key.txt", fileobj)
|
||||
|
||||
mock_client.head_bucket.assert_called_once()
|
||||
|
||||
|
||||
class TestExists:
|
||||
"""Tests for exists method."""
|
||||
|
||||
def test_exists_returns_true_when_object_exists(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that exists returns True when object exists."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_client.head_object.return_value = {}
|
||||
|
||||
result = s3_backend.exists("test/key.txt")
|
||||
|
||||
assert result is True
|
||||
mock_client.head_object.assert_called_once_with(
|
||||
Bucket="test-bucket", Key="test/key.txt"
|
||||
)
|
||||
|
||||
def test_exists_returns_false_when_object_not_found_404(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that exists returns False on 404 error."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
error_response = {"Error": {"Code": "404"}}
|
||||
mock_client.head_object.side_effect = ClientError(error_response, "HeadObject")
|
||||
|
||||
result = s3_backend.exists("test/key.txt")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_exists_returns_false_when_no_such_key(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that exists returns False on NoSuchKey error."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
error_response = {"Error": {"Code": "NoSuchKey"}}
|
||||
mock_client.head_object.side_effect = ClientError(error_response, "HeadObject")
|
||||
|
||||
result = s3_backend.exists("test/key.txt")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_exists_raises_on_other_errors(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that other errors are raised."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
error_response = {"Error": {"Code": "AccessDenied"}}
|
||||
mock_client.head_object.side_effect = ClientError(error_response, "HeadObject")
|
||||
|
||||
with pytest.raises(ClientError):
|
||||
s3_backend.exists("test/key.txt")
|
||||
|
||||
|
||||
class TestSize:
|
||||
"""Tests for size method."""
|
||||
|
||||
def test_size_returns_content_length(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that size returns ContentLength from head_object."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_client.head_object.return_value = {"ContentLength": 12345}
|
||||
|
||||
result = s3_backend.size("test/key.txt")
|
||||
|
||||
assert result == 12345
|
||||
mock_client.head_object.assert_called_once_with(
|
||||
Bucket="test-bucket", Key="test/key.txt"
|
||||
)
|
||||
|
||||
def test_size_returns_zero_when_no_content_length(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that size returns 0 when ContentLength is missing."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_client.head_object.return_value = {}
|
||||
|
||||
result = s3_backend.size("test/key.txt")
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestDelete:
|
||||
"""Tests for delete method."""
|
||||
|
||||
def test_delete_calls_delete_object(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that delete calls delete_object."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
s3_backend.delete("test/key.txt")
|
||||
|
||||
mock_client.delete_object.assert_called_once_with(
|
||||
Bucket="test-bucket", Key="test/key.txt"
|
||||
)
|
||||
|
||||
def test_delete_ensures_bucket(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that delete calls ensure_bucket."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
s3_backend.delete("test/key.txt")
|
||||
|
||||
mock_client.head_bucket.assert_called_once()
|
||||
|
||||
|
||||
class TestRead:
|
||||
"""Tests for read method."""
|
||||
|
||||
def test_read_returns_body_content(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that read returns the object body content."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
mock_body = MagicMock()
|
||||
mock_body.read.return_value = b"file content"
|
||||
mock_client.get_object.return_value = {"Body": mock_body}
|
||||
|
||||
result = s3_backend.read("test/key.txt")
|
||||
|
||||
assert result == b"file content"
|
||||
mock_client.get_object.assert_called_once_with(
|
||||
Bucket="test-bucket", Key="test/key.txt"
|
||||
)
|
||||
|
||||
def test_read_ensures_bucket(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that read calls ensure_bucket."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
mock_body = MagicMock()
|
||||
mock_body.read.return_value = b"content"
|
||||
mock_client.get_object.return_value = {"Body": mock_body}
|
||||
|
||||
s3_backend.read("test/key.txt")
|
||||
|
||||
mock_client.head_bucket.assert_called_once()
|
||||
|
||||
|
||||
class TestGenerateUrl:
|
||||
"""Tests for generate_url method."""
|
||||
|
||||
def test_generate_url_returns_presigned_url(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that generate_url returns a presigned URL."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_presign_client = mock_boto3_session["presign_client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_presign_client.generate_presigned_url.return_value = (
|
||||
"https://example.com/presigned-url"
|
||||
)
|
||||
|
||||
result = s3_backend.generate_url("test/key.txt")
|
||||
|
||||
assert result == "https://example.com/presigned-url"
|
||||
|
||||
def test_generate_url_uses_correct_parameters(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test that generate_url uses correct parameters."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_presign_client = mock_boto3_session["presign_client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
s3_backend.generate_url("test/key.txt")
|
||||
|
||||
mock_presign_client.generate_presigned_url.assert_called_once_with(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": "test-bucket", "Key": "test/key.txt"},
|
||||
ExpiresIn=3600,
|
||||
)
|
||||
|
||||
def test_generate_url_uses_custom_expiration(self, mock_boto3_session):
|
||||
"""Test that generate_url uses custom expiration time."""
|
||||
config = S3Config(
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
bucket_name="bucket",
|
||||
endpoint_url_internal="http://localhost:9000",
|
||||
endpoint_url_public=None,
|
||||
presign_expires_seconds=7200,
|
||||
)
|
||||
|
||||
backend = S3StorageBackend(config)
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_presign_client = mock_boto3_session["presign_client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
backend.generate_url("test/key.txt")
|
||||
|
||||
call_kwargs = mock_presign_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 7200
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Integration-style tests for common usage scenarios."""
|
||||
|
||||
def test_upload_then_check_exists(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test uploading a file and checking it exists."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_client.head_object.return_value = {}
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
s3_backend.upload_fileobj("test/file.txt", fileobj, content_type="text/plain")
|
||||
|
||||
# Check exists
|
||||
result = s3_backend.exists("test/file.txt")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_upload_then_read_back(
|
||||
self, s3_backend: S3StorageBackend, mock_boto3_session
|
||||
):
|
||||
"""Test uploading a file and reading it back."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
content = b"test file content"
|
||||
mock_body = MagicMock()
|
||||
mock_body.read.return_value = content
|
||||
mock_client.get_object.return_value = {"Body": mock_body}
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(content)
|
||||
s3_backend.upload_fileobj("test/file.txt", fileobj, content_type="text/plain")
|
||||
|
||||
# Read back
|
||||
result = s3_backend.read("test/file.txt")
|
||||
|
||||
assert result == content
|
||||
|
||||
def test_upload_then_delete(self, s3_backend: S3StorageBackend, mock_boto3_session):
|
||||
"""Test uploading a file and then deleting it."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
s3_backend.upload_fileobj("test/file.txt", fileobj, content_type="text/plain")
|
||||
|
||||
# Delete
|
||||
s3_backend.delete("test/file.txt")
|
||||
|
||||
mock_client.delete_object.assert_called_once_with(
|
||||
Bucket="test-bucket", Key="test/file.txt"
|
||||
)
|
||||
|
||||
def test_full_lifecycle(self, s3_backend: S3StorageBackend, mock_boto3_session):
|
||||
"""Test full file lifecycle: upload, check, get size, get url, delete."""
|
||||
mock_client = mock_boto3_session["client"]
|
||||
mock_presign_client = mock_boto3_session["presign_client"]
|
||||
mock_client.head_bucket.return_value = {}
|
||||
mock_client.head_object.return_value = {"ContentLength": 100}
|
||||
mock_presign_client.generate_presigned_url.return_value = "https://url"
|
||||
|
||||
content = b"test file content"
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(content)
|
||||
s3_backend.upload_fileobj("media/file.mp4", fileobj, content_type="video/mp4")
|
||||
|
||||
# Check exists
|
||||
assert s3_backend.exists("media/file.mp4") is True
|
||||
|
||||
# Get size
|
||||
assert s3_backend.size("media/file.mp4") == 100
|
||||
|
||||
# Generate URL
|
||||
url = s3_backend.generate_url("media/file.mp4")
|
||||
assert url == "https://url"
|
||||
|
||||
# Delete
|
||||
s3_backend.delete("media/file.mp4")
|
||||
|
||||
# Verify all operations were called
|
||||
assert mock_client.upload_fileobj.called
|
||||
assert mock_client.delete_object.called
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Unit tests for StorageService (async wrapper for storage backends).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cpv3.infrastructure.storage.base import StorageService
|
||||
from cpv3.infrastructure.storage.types import FileInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_backend() -> MagicMock:
|
||||
"""Create a mock storage backend."""
|
||||
backend = MagicMock()
|
||||
backend.upload_fileobj = MagicMock()
|
||||
backend.download_fileobj = MagicMock()
|
||||
backend.exists = MagicMock(return_value=True)
|
||||
backend.size = MagicMock(return_value=1024)
|
||||
backend.delete = MagicMock()
|
||||
backend.read = MagicMock(return_value=b"file content")
|
||||
backend.generate_url = MagicMock(return_value="https://example.com/file.txt")
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage_service(mock_backend: MagicMock) -> StorageService:
|
||||
"""Create a StorageService with mocked backend."""
|
||||
return StorageService(mock_backend)
|
||||
|
||||
|
||||
class TestStorageServiceUpload:
|
||||
"""Tests for StorageService upload functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_fileobj_with_generated_name(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test uploading a file with auto-generated name."""
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="original.txt",
|
||||
folder="uploads",
|
||||
gen_name=True,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
# Key should contain folder and have .txt extension
|
||||
assert key.startswith("uploads/")
|
||||
assert key.endswith(".txt")
|
||||
# Key should not contain original filename
|
||||
assert "original" not in key
|
||||
|
||||
mock_backend.upload_fileobj.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_fileobj_with_original_name(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test uploading a file keeping original name."""
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="myfile.txt",
|
||||
folder="uploads",
|
||||
gen_name=False,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
assert key == "uploads/myfile.txt"
|
||||
mock_backend.upload_fileobj.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_fileobj_without_folder(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test uploading a file without folder."""
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="myfile.txt",
|
||||
folder="",
|
||||
gen_name=False,
|
||||
)
|
||||
|
||||
assert key == "myfile.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_fileobj_without_extension(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test uploading a file without extension."""
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="noextension",
|
||||
folder="uploads",
|
||||
gen_name=True,
|
||||
)
|
||||
|
||||
# Should generate UUID without extension
|
||||
assert key.startswith("uploads/")
|
||||
assert "." not in key.split("/")[-1] # No extension in filename
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_fileobj_seeks_to_start(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test that upload_fileobj seeks to start of file."""
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
fileobj.seek(5) # Move position
|
||||
|
||||
await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="test.txt",
|
||||
folder="",
|
||||
gen_name=False,
|
||||
)
|
||||
|
||||
# Backend should receive fileobj that's been seeked to 0
|
||||
mock_backend.upload_fileobj.assert_called_once()
|
||||
# The call should have seeked the fileobj
|
||||
assert fileobj.tell() == 0 or mock_backend.upload_fileobj.called
|
||||
|
||||
|
||||
class TestStorageServiceExists:
|
||||
"""Tests for StorageService exists functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists_returns_true(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test exists returns True when file exists."""
|
||||
mock_backend.exists.return_value = True
|
||||
|
||||
result = await storage_service.exists("test/file.txt")
|
||||
|
||||
assert result is True
|
||||
mock_backend.exists.assert_called_once_with("test/file.txt")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists_returns_false(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test exists returns False when file doesn't exist."""
|
||||
mock_backend.exists.return_value = False
|
||||
|
||||
result = await storage_service.exists("nonexistent.txt")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestStorageServiceDelete:
|
||||
"""Tests for StorageService delete functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_calls_backend(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test delete calls backend delete."""
|
||||
await storage_service.delete("test/file.txt")
|
||||
|
||||
mock_backend.delete.assert_called_once_with("test/file.txt")
|
||||
|
||||
|
||||
class TestStorageServiceSize:
|
||||
"""Tests for StorageService size functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_returns_file_size(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test size returns file size from backend."""
|
||||
mock_backend.size.return_value = 12345
|
||||
|
||||
result = await storage_service.size("test/file.txt")
|
||||
|
||||
assert result == 12345
|
||||
mock_backend.size.assert_called_once_with("test/file.txt")
|
||||
|
||||
|
||||
class TestStorageServiceRead:
|
||||
"""Tests for StorageService read functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_returns_content(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test read returns file content from backend."""
|
||||
expected_content = b"file content bytes"
|
||||
mock_backend.read.return_value = expected_content
|
||||
|
||||
result = await storage_service.read("test/file.txt")
|
||||
|
||||
assert result == expected_content
|
||||
mock_backend.read.assert_called_once_with("test/file.txt")
|
||||
|
||||
|
||||
class TestStorageServiceUrl:
|
||||
"""Tests for StorageService url functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_returns_presigned_url(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test url returns presigned URL from backend."""
|
||||
expected_url = "https://s3.example.com/bucket/file.txt?signature=xyz"
|
||||
mock_backend.generate_url.return_value = expected_url
|
||||
|
||||
result = await storage_service.url("test/file.txt")
|
||||
|
||||
assert result == expected_url
|
||||
mock_backend.generate_url.assert_called_once_with("test/file.txt")
|
||||
|
||||
|
||||
class TestStorageServiceGetFileInfo:
|
||||
"""Tests for StorageService get_file_info functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_info_returns_file_info(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test get_file_info returns FileInfo with all details."""
|
||||
mock_backend.exists.return_value = True
|
||||
mock_backend.generate_url.return_value = "https://example.com/uploads/file.txt"
|
||||
mock_backend.size.return_value = 2048
|
||||
|
||||
result = await storage_service.get_file_info("uploads/file.txt")
|
||||
|
||||
assert isinstance(result, FileInfo)
|
||||
assert result.file_path == "uploads/file.txt"
|
||||
assert result.file_url == "https://example.com/uploads/file.txt"
|
||||
assert result.file_size == 2048
|
||||
assert result.filename == "file.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_info_raises_when_not_found(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test get_file_info raises FileNotFoundError when file doesn't exist."""
|
||||
mock_backend.exists.return_value = False
|
||||
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
await storage_service.get_file_info("nonexistent.txt")
|
||||
|
||||
assert "nonexistent.txt" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_info_extracts_filename_from_path(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test get_file_info correctly extracts filename from path."""
|
||||
mock_backend.exists.return_value = True
|
||||
mock_backend.generate_url.return_value = "https://example.com/path"
|
||||
mock_backend.size.return_value = 100
|
||||
|
||||
result = await storage_service.get_file_info("deep/nested/path/myfile.mp4")
|
||||
|
||||
assert result.filename == "myfile.mp4"
|
||||
|
||||
|
||||
class TestStorageServiceIntegration:
|
||||
"""Integration-style tests for StorageService."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_then_get_info(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test uploading a file and getting its info."""
|
||||
mock_backend.exists.return_value = True
|
||||
mock_backend.size.return_value = 1024
|
||||
mock_backend.generate_url.return_value = "https://example.com/url"
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(b"test content")
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="video.mp4",
|
||||
folder="media",
|
||||
gen_name=False,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
|
||||
# Get info
|
||||
info = await storage_service.get_file_info(key)
|
||||
|
||||
assert info.file_path == key
|
||||
assert info.file_size == 1024
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_workflow(
|
||||
self, storage_service: StorageService, mock_backend: MagicMock
|
||||
):
|
||||
"""Test full file management workflow."""
|
||||
mock_backend.exists.return_value = True
|
||||
mock_backend.size.return_value = 500
|
||||
mock_backend.read.return_value = b"content"
|
||||
mock_backend.generate_url.return_value = "https://url"
|
||||
|
||||
# Upload
|
||||
fileobj = io.BytesIO(b"content")
|
||||
key = await storage_service.upload_fileobj(
|
||||
fileobj=fileobj,
|
||||
file_name="doc.pdf",
|
||||
folder="documents",
|
||||
gen_name=False,
|
||||
)
|
||||
|
||||
# Check exists
|
||||
exists = await storage_service.exists(key)
|
||||
assert exists is True
|
||||
|
||||
# Get size
|
||||
size = await storage_service.size(key)
|
||||
assert size == 500
|
||||
|
||||
# Read content
|
||||
content = await storage_service.read(key)
|
||||
assert content == b"content"
|
||||
|
||||
# Get URL
|
||||
url = await storage_service.url(key)
|
||||
assert url == "https://url"
|
||||
|
||||
# Delete
|
||||
await storage_service.delete(key)
|
||||
mock_backend.delete.assert_called_once()
|
||||
|
||||
|
||||
class TestFileInfoDataclass:
|
||||
"""Tests for FileInfo dataclass."""
|
||||
|
||||
def test_file_info_creation(self):
|
||||
"""Test creating FileInfo with all fields."""
|
||||
info = FileInfo(
|
||||
file_path="path/to/file.txt",
|
||||
file_url="https://example.com/file.txt",
|
||||
file_size=1024,
|
||||
filename="file.txt",
|
||||
)
|
||||
|
||||
assert info.file_path == "path/to/file.txt"
|
||||
assert info.file_url == "https://example.com/file.txt"
|
||||
assert info.file_size == 1024
|
||||
assert info.filename == "file.txt"
|
||||
|
||||
def test_file_info_optional_fields(self):
|
||||
"""Test creating FileInfo with optional fields as None."""
|
||||
info = FileInfo(
|
||||
file_path="path/to/file.txt",
|
||||
file_url="https://example.com/file.txt",
|
||||
)
|
||||
|
||||
assert info.file_size is None
|
||||
assert info.filename is None
|
||||
|
||||
def test_file_info_is_frozen(self):
|
||||
"""Test that FileInfo is immutable."""
|
||||
info = FileInfo(
|
||||
file_path="path/to/file.txt",
|
||||
file_url="https://example.com/file.txt",
|
||||
)
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
info.file_path = "new/path" # type: ignore
|
||||
Reference in New Issue
Block a user