feature: create multitasking

This commit is contained in:
Daniil
2026-02-04 02:19:50 +03:00
parent 67e0f22b4f
commit a25bf623ea
24 changed files with 5227 additions and 21 deletions
+217 -17
View File
@@ -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()
+159
View File
@@ -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
+293
View File
@@ -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
+365
View File
@@ -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
+461
View File
@@ -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
+212
View File
@@ -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
+595
View File
@@ -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
+374
View File
@@ -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