""" Integration tests for S3FileStorage against a SeaweedFS Docker container. SeaweedFS (Apache 2.0) provides an S3-compatible API via `weed server -s3`. Requires Docker to be running. Tests are skipped automatically if Docker is unavailable. """ import json import logging import os import subprocess import tempfile import time import uuid import httpx import pytest from httpx import ASGITransport, AsyncClient logger = logging.getLogger(__name__) try: from testcontainers.core.container import DockerContainer _has_testcontainers = True except ImportError: _has_testcontainers = False _in_ci = os.getenv("CI") == "true" pytestmark = [ pytest.mark.skipif(not _has_testcontainers, reason="testcontainers not installed"), pytest.mark.skipif(_in_ci, reason="SeaweedFS Docker image pull too slow in CI"), pytest.mark.timeout(300), ] SEAWEEDFS_S3_PORT = 8333 TEST_BUCKET = "hindsight-test" ACCESS_KEY = "test_access_key" SECRET_KEY = "test_secret_key" # SeaweedFS S3 IAM config granting full access to our test credentials _S3_CONFIG = { "identities": [ { "name": "test-user", "credentials": [{"accessKey": ACCESS_KEY, "secretKey": SECRET_KEY}], "actions": ["Admin", "Read", "Write", "List"], } ] } def _docker_available() -> bool: """Check if Docker daemon is running.""" try: result = subprocess.run( ["docker", "info"], capture_output=True, timeout=5, ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False def _wait_for_seaweedfs(endpoint: str, timeout: int = 30) -> None: """Poll SeaweedFS S3 endpoint until ready.""" deadline = time.time() + timeout while time.time() < deadline: try: resp = httpx.get(endpoint, timeout=2) # 200 = no auth, 403 = auth enabled but gateway is up — either means ready if resp.status_code in (200, 403): logger.info("SeaweedFS S3 is ready at %s", endpoint) return except httpx.HTTPError: pass time.sleep(0.5) raise TimeoutError(f"SeaweedFS did not become ready at {endpoint} within {timeout}s") @pytest.fixture(scope="module") def seaweedfs_container(): """Start a SeaweedFS container for the test module, shared across all tests. Mounts an s3.json config file to set up S3 credentials for the test user. """ if not _docker_available(): pytest.skip("Docker is not available") # Write S3 IAM config to a temp file that persists for the module scope s3_config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) json.dump(_S3_CONFIG, s3_config_file) s3_config_file.flush() container = ( DockerContainer(image="chrislusf/seaweedfs:latest") .with_exposed_ports(SEAWEEDFS_S3_PORT) .with_volume_mapping(s3_config_file.name, "/etc/seaweedfs/s3.json", "ro") .with_command( f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0" ) ) container.start() try: host = container.get_container_host_ip() port = container.get_exposed_port(SEAWEEDFS_S3_PORT) endpoint = f"http://{host}:{port}" _wait_for_seaweedfs(endpoint, timeout=240) # Create test bucket using obstore (proper SigV4 signing) import obstore as obs from obstore.store import S3Store admin_store = S3Store( TEST_BUCKET, endpoint=endpoint, region="us-east-1", access_key_id=ACCESS_KEY, secret_access_key=SECRET_KEY, allow_http=True, ) # SeaweedFS auto-creates buckets on first write obs.put(admin_store, ".bucket-init", b"") obs.delete(admin_store, ".bucket-init") logger.info("Test bucket '%s' is ready", TEST_BUCKET) yield { "endpoint": endpoint, "access_key": ACCESS_KEY, "secret_key": SECRET_KEY, "bucket": TEST_BUCKET, } finally: container.stop() import os os.unlink(s3_config_file.name) @pytest.fixture def s3_storage(seaweedfs_container): """Create an S3FileStorage instance pointing at the SeaweedFS container.""" from hindsight_api.engine.storage.s3 import S3FileStorage return S3FileStorage( bucket=seaweedfs_container["bucket"], region="us-east-1", endpoint=seaweedfs_container["endpoint"], access_key_id=seaweedfs_container["access_key"], secret_access_key=seaweedfs_container["secret_key"], ) @pytest.mark.asyncio async def test_s3_storage_store_and_retrieve(s3_storage): """Store a file, retrieve it, verify bytes match.""" content = b"Hello, SeaweedFS! This is a test file." key = f"test/{uuid.uuid4()}.txt" stored_key = await s3_storage.store( file_data=content, key=key, metadata={"content_type": "text/plain"}, ) assert stored_key == key retrieved = await s3_storage.retrieve(key) assert retrieved == content @pytest.mark.asyncio async def test_s3_storage_exists_and_delete(s3_storage): """Store, check exists=True, delete, check exists=False.""" content = b"File to be deleted." key = f"test/{uuid.uuid4()}.txt" await s3_storage.store(file_data=content, key=key) assert await s3_storage.exists(key) is True await s3_storage.delete(key) assert await s3_storage.exists(key) is False @pytest.mark.asyncio async def test_s3_storage_file_not_found(s3_storage): """Retrieve a non-existent key, expect FileNotFoundError.""" with pytest.raises(FileNotFoundError): await s3_storage.retrieve(f"nonexistent/{uuid.uuid4()}.txt") @pytest.mark.asyncio async def test_s3_storage_get_download_url(s3_storage): """Store a file, get a presigned URL, verify it's a valid URL string.""" content = b"Presigned URL test content." key = f"test/{uuid.uuid4()}.txt" await s3_storage.store(file_data=content, key=key) url = await s3_storage.get_download_url(key, expires_in=300) assert isinstance(url, str) assert url.startswith("http") assert key in url @pytest.mark.asyncio async def test_s3_file_retain_api_end_to_end(seaweedfs_container, memory_no_llm_verify): """Full HTTP API flow: upload file via /files/retain with S3 storage backend.""" from hindsight_api.api.http import create_app from hindsight_api.engine.storage.s3 import S3FileStorage # Swap the engine's file storage to use the SeaweedFS-backed S3 storage original_storage = memory_no_llm_verify._file_storage s3_storage = S3FileStorage( bucket=seaweedfs_container["bucket"], region="us-east-1", endpoint=seaweedfs_container["endpoint"], access_key_id=seaweedfs_container["access_key"], secret_access_key=seaweedfs_container["secret_key"], ) memory_no_llm_verify._file_storage = s3_storage try: app = create_app(memory_no_llm_verify, initialize_memory=False) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: bank_id = f"test-s3-bank-{uuid.uuid4().hex[:8]}" bank_response = await client.put(f"/v1/default/banks/{bank_id}", json={"name": "S3 Test Bank"}) assert bank_response.status_code in (200, 201) txt_content = b"Alice works at Acme Corp. She joined in 2024." request_data = { "document_tags": ["s3-test"], "async": True, } files = {"files": ("notes.txt", txt_content, "text/plain")} data = {"request": json.dumps(request_data)} response = await client.post( f"/v1/default/banks/{bank_id}/files/retain", files=files, data=data, ) assert response.status_code == 200 result = response.json() assert "operation_ids" in result assert len(result["operation_ids"]) == 1 finally: memory_no_llm_verify._file_storage = original_storage