From 98641c24e4b27205c8070e47cd62f4601b8f6cb2 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 3 Apr 2026 16:55:11 +0300 Subject: [PATCH] S3 storage tests --- haiku_rag_slim/haiku/rag/app.py | 14 ++- tests/docker/docker-compose.minio.yml | 22 ++++ tests/test_s3_integration.py | 152 ++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/docker/docker-compose.minio.yml create mode 100644 tests/test_s3_integration.py diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index e9d968d5..1b09d38a 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -62,8 +62,9 @@ class HaikuRAGApp: # pragma: no cover # Create the database client = HaikuRAG(db_path=self.db_path, config=self.config, create=True) client.close() + display_path = self.config.lancedb.uri if not self._is_local else self.db_path self.console.print( - f"[bold green]Database initialized at {self.db_path}[/bold green]" + f"[bold green]Database initialized at {display_path}[/bold green]" ) async def info(self): @@ -71,10 +72,11 @@ class HaikuRAGApp: # pragma: no cover from haiku.rag.store.engine import Store, connect_lancedb - # Basic: show path + # Basic: show path/URI self.console.print("[bold]haiku.rag database info[/bold]") + display_path = self.config.lancedb.uri if not self._is_local else self.db_path self.console.print( - f" [repr.attrib_name]path[/repr.attrib_name]: {self.db_path}" + f" [repr.attrib_name]path[/repr.attrib_name]: {display_path}" ) if self._is_local and not self.db_path.exists(): @@ -84,6 +86,12 @@ class HaikuRAGApp: # pragma: no cover # Connect without going through Store to avoid upgrades/validation writes db = connect_lancedb(self.config, self.db_path) + if not db.list_tables().tables: + self.console.print( + "[red]Database is empty. Use 'haiku-rag init' to initialize.[/red]" + ) + return + versions = get_package_versions() store = Store( diff --git a/tests/docker/docker-compose.minio.yml b/tests/docker/docker-compose.minio.yml new file mode 100644 index 00000000..2928d0c7 --- /dev/null +++ b/tests/docker/docker-compose.minio.yml @@ -0,0 +1,22 @@ +services: + minio: + image: minio/minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + + createbucket: + image: minio/mc + depends_on: + - minio + entrypoint: > + /bin/sh -c " + sleep 2; + mc alias set myminio http://minio:9000 minioadmin minioadmin; + mc mb myminio/test-bucket; + exit 0; + " diff --git a/tests/test_s3_integration.py b/tests/test_s3_integration.py new file mode 100644 index 00000000..85a7e08f --- /dev/null +++ b/tests/test_s3_integration.py @@ -0,0 +1,152 @@ +# Start MinIO before running: +# docker compose -f tests/docker/docker-compose.minio.yml up -d +# Stop after: +# docker compose -f tests/docker/docker-compose.minio.yml down -v + +import socket +from uuid import uuid4 + +import pytest + +from haiku.rag.app import HaikuRAGApp +from haiku.rag.client import HaikuRAG +from haiku.rag.config.models import AppConfig, LanceDBConfig +from haiku.rag.store.engine import Store + +MINIO_ENDPOINT = "http://localhost:9000" +MINIO_BUCKET = "test-bucket" +MINIO_STORAGE_OPTIONS = { + "aws_access_key_id": "minioadmin", + "aws_secret_access_key": "minioadmin", + "endpoint": MINIO_ENDPOINT, + "region": "us-east-1", + "allow_http": "true", +} + + +def _minio_available() -> bool: + try: + s = socket.create_connection(("localhost", 9000), timeout=1) + s.close() + return True + except OSError: + return False + + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not _minio_available(), reason="MinIO not running on localhost:9000" + ), +] + + +def _make_config() -> AppConfig: + unique_prefix = uuid4().hex[:8] + return AppConfig( + lancedb=LanceDBConfig( + uri=f"s3://{MINIO_BUCKET}/test-{unique_prefix}", + storage_options=MINIO_STORAGE_OPTIONS, + ) + ) + + +def test_store_connect_and_create(tmp_path): + config = _make_config() + store = Store(tmp_path / "unused", config=config, create=True) + stats = store.get_stats() + assert stats["documents"]["exists"] + assert stats["chunks"]["exists"] + store.close() + + +@pytest.mark.asyncio +async def test_store_vacuum(tmp_path): + config = _make_config() + store = Store(tmp_path / "unused", config=config, create=True) + await store.vacuum() + store.close() + + +def test_store_add_document(tmp_path): + from haiku.rag.store.engine import DocumentRecord + + config = _make_config() + store = Store(tmp_path / "unused", config=config, create=True) + + doc = DocumentRecord(content="The quick brown fox jumps over the lazy dog.") + store.documents_table.add([doc]) + + stats = store.get_stats() + assert stats["documents"]["num_rows"] == 1 + store.close() + + +@pytest.mark.asyncio +async def test_client_create_document(tmp_path): + config = _make_config() + async with HaikuRAG(tmp_path / "unused", config=config, create=True) as rag: + doc = await rag.create_document( + "Python is a programming language.", uri="test://python" + ) + assert doc.id + assert doc.uri == "test://python" + + +@pytest.mark.asyncio +async def test_client_list_documents(tmp_path): + config = _make_config() + async with HaikuRAG(tmp_path / "unused", config=config, create=True) as rag: + await rag.create_document("First document.", uri="test://first") + await rag.create_document("Second document.", uri="test://second") + + docs = await rag.list_documents() + assert len(docs) == 2 + + +@pytest.mark.asyncio +async def test_client_search(tmp_path): + config = _make_config() + async with HaikuRAG(tmp_path / "unused", config=config, create=True) as rag: + await rag.create_document( + "The Eiffel Tower is located in Paris, France.", uri="test://eiffel" + ) + results = await rag.search("Eiffel Tower") + assert len(results) > 0 + assert "Eiffel" in results[0].content + + +@pytest.mark.asyncio +async def test_client_delete_document(tmp_path): + config = _make_config() + async with HaikuRAG(tmp_path / "unused", config=config, create=True) as rag: + doc = await rag.create_document("Temporary document.", uri="test://temp") + await rag.delete_document(doc.id) + docs = await rag.list_documents() + assert len(docs) == 0 + + +@pytest.mark.asyncio +async def test_app_info(tmp_path, capsys): + config = _make_config() + # Initialize the database first + async with HaikuRAG(tmp_path / "unused", config=config, create=True) as rag: + await rag.create_document("Info test document.", uri="test://info") + + app = HaikuRAGApp(db_path=tmp_path / "unused", config=config) + await app.info() + + out = capsys.readouterr().out + assert "path:" in out + assert config.lancedb.uri in out + assert "documents: 1" in out + + +@pytest.mark.asyncio +async def test_app_info_empty_db(tmp_path, capsys): + config = _make_config() + app = HaikuRAGApp(db_path=tmp_path / "unused", config=config) + await app.info() + + out = capsys.readouterr().out + assert "Database is empty" in out