380 lines
12 KiB
Python
380 lines
12 KiB
Python
"""
|
|
Test configuration and fixtures for Orchard backend tests.
|
|
|
|
This module provides:
|
|
- Database fixtures with test isolation
|
|
- Mock S3 storage using moto
|
|
- Shared pytest fixtures
|
|
"""
|
|
|
|
import os
|
|
import pytest
|
|
|
|
|
|
# =============================================================================
|
|
# Pytest Markers
|
|
# =============================================================================
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Register custom pytest markers."""
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"auth_intensive: marks tests that make many login requests (excluded from CI integration tests due to rate limiting)",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"integration: marks tests as integration tests",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"large: marks tests that handle large files (slow)",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"slow: marks tests as slow running",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"requires_direct_s3: marks tests that require direct S3/MinIO access (skipped in CI where S3 is not directly accessible)",
|
|
)
|
|
|
|
|
|
import io
|
|
from typing import Generator
|
|
from unittest.mock import MagicMock
|
|
|
|
# Set test environment defaults before importing app modules
|
|
# Use setdefault to NOT override existing env vars (from docker-compose)
|
|
os.environ.setdefault("ORCHARD_DATABASE_HOST", "localhost")
|
|
os.environ.setdefault("ORCHARD_DATABASE_PORT", "5432")
|
|
os.environ.setdefault("ORCHARD_DATABASE_USER", "test")
|
|
os.environ.setdefault("ORCHARD_DATABASE_PASSWORD", "test")
|
|
os.environ.setdefault("ORCHARD_DATABASE_DBNAME", "orchard_test")
|
|
os.environ.setdefault("ORCHARD_S3_ENDPOINT", "http://localhost:9000")
|
|
os.environ.setdefault("ORCHARD_S3_BUCKET", "test-bucket")
|
|
os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test")
|
|
os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "test")
|
|
|
|
# Re-export factory functions for backward compatibility
|
|
from tests.factories import (
|
|
create_test_file,
|
|
compute_sha256,
|
|
compute_md5,
|
|
compute_sha1,
|
|
upload_test_file,
|
|
generate_content,
|
|
generate_content_with_hash,
|
|
TEST_CONTENT_HELLO,
|
|
TEST_HASH_HELLO,
|
|
TEST_MD5_HELLO,
|
|
TEST_SHA1_HELLO,
|
|
TEST_CONTENT_EMPTY,
|
|
TEST_CONTENT_BINARY,
|
|
TEST_HASH_BINARY,
|
|
get_s3_client,
|
|
get_s3_bucket,
|
|
list_s3_objects_by_hash,
|
|
count_s3_objects_by_prefix,
|
|
s3_object_exists,
|
|
delete_s3_object_by_hash,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Mock Storage Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
class MockS3Client:
|
|
"""Mock S3 client for unit testing without actual S3/MinIO."""
|
|
|
|
def __init__(self):
|
|
self.objects = {} # key -> content
|
|
self.bucket = "test-bucket"
|
|
|
|
def put_object(self, Bucket: str, Key: str, Body: bytes) -> dict:
|
|
self.objects[Key] = Body
|
|
return {"ETag": f'"{compute_md5(Body)}"'}
|
|
|
|
def get_object(self, Bucket: str, Key: str, **kwargs) -> dict:
|
|
if Key not in self.objects:
|
|
raise Exception("NoSuchKey")
|
|
content = self.objects[Key]
|
|
return {
|
|
"Body": io.BytesIO(content),
|
|
"ContentLength": len(content),
|
|
}
|
|
|
|
def head_object(self, Bucket: str, Key: str) -> dict:
|
|
if Key not in self.objects:
|
|
from botocore.exceptions import ClientError
|
|
|
|
error_response = {"Error": {"Code": "404", "Message": "Not Found"}}
|
|
raise ClientError(error_response, "HeadObject")
|
|
content = self.objects[Key]
|
|
return {
|
|
"ContentLength": len(content),
|
|
"ETag": f'"{compute_md5(content)}"',
|
|
}
|
|
|
|
def delete_object(self, Bucket: str, Key: str) -> dict:
|
|
if Key in self.objects:
|
|
del self.objects[Key]
|
|
return {}
|
|
|
|
def head_bucket(self, Bucket: str) -> dict:
|
|
return {}
|
|
|
|
def create_multipart_upload(self, Bucket: str, Key: str) -> dict:
|
|
return {"UploadId": "test-upload-id"}
|
|
|
|
def upload_part(
|
|
self, Bucket: str, Key: str, UploadId: str, PartNumber: int, Body: bytes
|
|
) -> dict:
|
|
return {"ETag": f'"{compute_md5(Body)}"'}
|
|
|
|
def complete_multipart_upload(
|
|
self, Bucket: str, Key: str, UploadId: str, MultipartUpload: dict
|
|
) -> dict:
|
|
return {"ETag": '"test-etag"'}
|
|
|
|
def abort_multipart_upload(self, Bucket: str, Key: str, UploadId: str) -> dict:
|
|
return {}
|
|
|
|
def generate_presigned_url(
|
|
self, ClientMethod: str, Params: dict, ExpiresIn: int
|
|
) -> str:
|
|
return f"https://test-bucket.s3.amazonaws.com/{Params['Key']}?presigned=true"
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_s3_client() -> MockS3Client:
|
|
"""Provide a mock S3 client for unit tests."""
|
|
return MockS3Client()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_storage(mock_s3_client):
|
|
"""
|
|
Provide a mock storage instance for unit tests.
|
|
|
|
Uses the MockS3Client to avoid actual S3/MinIO calls.
|
|
"""
|
|
from app.storage import S3Storage
|
|
|
|
storage = S3Storage.__new__(S3Storage)
|
|
storage.client = mock_s3_client
|
|
storage.bucket = "test-bucket"
|
|
storage._active_uploads = {}
|
|
|
|
return storage
|
|
|
|
|
|
# =============================================================================
|
|
# Database Fixtures (for integration tests)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_db_url():
|
|
"""Get the test database URL."""
|
|
return (
|
|
f"postgresql://{os.environ['ORCHARD_DATABASE_USER']}:"
|
|
f"{os.environ['ORCHARD_DATABASE_PASSWORD']}@"
|
|
f"{os.environ['ORCHARD_DATABASE_HOST']}:"
|
|
f"{os.environ['ORCHARD_DATABASE_PORT']}/"
|
|
f"{os.environ['ORCHARD_DATABASE_DBNAME']}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Client Fixtures (for API tests)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def test_app():
|
|
"""
|
|
Create a test FastAPI application.
|
|
|
|
Note: This requires the database to be available for integration tests.
|
|
For unit tests, use mock_storage fixture instead.
|
|
"""
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
# =============================================================================
|
|
# Integration Test Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def integration_client():
|
|
"""
|
|
Create an authenticated test client for integration tests.
|
|
|
|
Uses the real database and MinIO from docker-compose.local.yml or deployed environment.
|
|
Authenticates as admin for write operations. Session-scoped to reuse login across tests.
|
|
|
|
Environment variables:
|
|
ORCHARD_TEST_URL: Base URL of the Orchard server (default: http://localhost:8080)
|
|
ORCHARD_TEST_USERNAME: Admin username for authentication (default: admin)
|
|
ORCHARD_TEST_PASSWORD: Admin password for authentication (default: changeme123)
|
|
"""
|
|
import httpx
|
|
|
|
# Connect to the running orchard-server container or deployed environment
|
|
base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080")
|
|
username = os.environ.get("ORCHARD_TEST_USERNAME", "admin")
|
|
password = os.environ.get("ORCHARD_TEST_PASSWORD", "changeme123")
|
|
|
|
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
|
# Login as admin to enable write operations
|
|
login_response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": username, "password": password},
|
|
)
|
|
if login_response.status_code != 200:
|
|
pytest.fail(
|
|
f"Authentication failed against {base_url}: {login_response.status_code} - {login_response.text}. "
|
|
f"Set ORCHARD_TEST_USERNAME and ORCHARD_TEST_PASSWORD environment variables if using non-default credentials."
|
|
)
|
|
|
|
# Verify cookie was set
|
|
if not client.cookies:
|
|
pytest.fail(
|
|
f"Login succeeded but no session cookie was set. Response headers: {login_response.headers}"
|
|
)
|
|
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_client():
|
|
"""
|
|
Create a function-scoped test client for authentication tests.
|
|
|
|
Unlike integration_client (session-scoped), this creates a fresh client
|
|
for each test. Use this for tests that manipulate authentication state
|
|
(login, logout, cookie clearing) to avoid polluting other tests.
|
|
|
|
Environment variables:
|
|
ORCHARD_TEST_URL: Base URL of the Orchard server (default: http://localhost:8080)
|
|
"""
|
|
import httpx
|
|
|
|
base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080")
|
|
|
|
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def unique_test_id():
|
|
"""Generate a unique ID for test isolation."""
|
|
import uuid
|
|
|
|
return f"test-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
@pytest.fixture
|
|
def test_project(integration_client, unique_test_id):
|
|
"""
|
|
Create a test project and clean it up after the test.
|
|
|
|
Yields the project name.
|
|
"""
|
|
project_name = f"test-project-{unique_test_id}"
|
|
|
|
# Create project
|
|
response = integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": project_name, "description": "Test project", "is_public": True},
|
|
)
|
|
assert response.status_code == 200, f"Failed to create project: {response.text}"
|
|
|
|
yield project_name
|
|
|
|
# Cleanup: delete project
|
|
try:
|
|
integration_client.delete(f"/api/v1/projects/{project_name}")
|
|
except Exception:
|
|
pass # Ignore cleanup errors
|
|
|
|
|
|
@pytest.fixture
|
|
def test_package(integration_client, test_project, unique_test_id):
|
|
"""
|
|
Create a test package within a test project.
|
|
|
|
Yields (project_name, package_name) tuple.
|
|
"""
|
|
package_name = f"test-package-{unique_test_id}"
|
|
|
|
# Create package
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": package_name, "description": "Test package"},
|
|
)
|
|
assert response.status_code == 200, f"Failed to create package: {response.text}"
|
|
|
|
yield (test_project, package_name)
|
|
|
|
# Cleanup handled by test_project fixture (cascade delete)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_content():
|
|
"""
|
|
Generate unique test content for each test.
|
|
|
|
Returns (content_bytes, expected_sha256) tuple.
|
|
"""
|
|
import uuid
|
|
|
|
content = f"test-content-{uuid.uuid4().hex}".encode()
|
|
sha256 = compute_sha256(content)
|
|
return (content, sha256)
|
|
|
|
|
|
@pytest.fixture
|
|
def sized_content():
|
|
"""
|
|
Factory fixture for generating content of specific sizes.
|
|
|
|
Usage:
|
|
def test_example(sized_content):
|
|
content, hash = sized_content(1024) # 1KB
|
|
content, hash = sized_content(1024 * 1024) # 1MB
|
|
"""
|
|
def _generate(size: int, seed: int = None):
|
|
return generate_content_with_hash(size, seed)
|
|
return _generate
|
|
|
|
|
|
# =============================================================================
|
|
# Size Constants for Tests
|
|
# =============================================================================
|
|
|
|
# Common file sizes for boundary testing
|
|
SIZE_1B = 1
|
|
SIZE_1KB = 1024
|
|
SIZE_10KB = 10 * 1024
|
|
SIZE_100KB = 100 * 1024
|
|
SIZE_1MB = 1024 * 1024
|
|
SIZE_5MB = 5 * 1024 * 1024
|
|
SIZE_10MB = 10 * 1024 * 1024
|
|
SIZE_50MB = 50 * 1024 * 1024
|
|
SIZE_100MB = 100 * 1024 * 1024
|
|
SIZE_250MB = 250 * 1024 * 1024
|
|
SIZE_500MB = 500 * 1024 * 1024
|
|
SIZE_1GB = 1024 * 1024 * 1024
|
|
|
|
# Chunk size boundaries (based on typical S3 multipart chunk sizes)
|
|
CHUNK_SIZE = 64 * 1024 # 64KB typical chunk
|
|
MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 100MB multipart threshold
|