""" 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", ) 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 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