From 27fa0d4631418fc331b08f938b5abdef71e50e3a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 22 May 2026 09:06:55 +0200 Subject: [PATCH] test(01-02): add Wave 0 scaffolds test_storage.py and test_alembic.py test_storage.py (6 xfail tests, STORE-02): - test_object_key_schema: regex {user_id}/{doc_id}/{uuid4}{ext} - test_filename_not_in_object_key: human filename never in MinIO key - test_storage_backend_abc_methods: incomplete subclass raises TypeError - test_get_storage_backend_returns_minio: factory returns MinIOBackend - test_put_object_uses_asyncio_to_thread: SDK call wrapped in to_thread - test_minio_backend_health_check_returns_bool: True/False on ok/error test_alembic.py (2 xfail tests, STORE-01 / D-02 / D-03): - test_migration_creates_all_tables: all 11 v1 tables after upgrade head - test_documents_user_id_nullable: user_id notnull=0 per D-03 --- backend/tests/test_alembic.py | 116 +++++++++++++++++++ backend/tests/test_storage.py | 211 ++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 backend/tests/test_alembic.py create mode 100644 backend/tests/test_storage.py diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py new file mode 100644 index 0000000..601a6bb --- /dev/null +++ b/backend/tests/test_alembic.py @@ -0,0 +1,116 @@ +""" +Wave 0 integration tests for Plan 03 (Alembic migration). + +Both tests are marked xfail(strict=False) because the Alembic migration and +db.models are implemented in Plan 03. The xfail markers will be removed once +Plan 03 lands and alembic upgrade head successfully creates all expected tables. + +Requirements covered: + STORE-01 — Platform migrated to PostgreSQL; full v1 schema applied + D-02 — groups table stub included in the Phase 1 migration + D-03 — documents.user_id is nullable in Phase 1 +""" +from __future__ import annotations + +import pytest + + +EXPECTED_TABLES = { + "users", + "quotas", + "refresh_tokens", + "folders", + "documents", + "topics", + "document_topics", + "shares", + "audit_log", + "cloud_connections", + "groups", +} + + +# --------------------------------------------------------------------------- +# Test 1: alembic upgrade head creates all 11 expected tables +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 03") +def test_migration_creates_all_tables(tmp_path, monkeypatch): + """After alembic upgrade head, the DB contains all 11 v1 tables (D-01, D-02).""" + try: + import alembic.command + from alembic.config import Config + except ImportError as exc: + pytest.skip(f"alembic not installed: {exc}") + + db_path = tmp_path / "test_migrate.db" + db_url = f"sqlite+aiosqlite:///{db_path}" + + monkeypatch.setenv("DATABASE_MIGRATE_URL", db_url) + + # Run alembic upgrade head using the Python API + alembic_cfg = Config("alembic.ini") + alembic_cfg.set_main_option("sqlalchemy.url", db_url) + + try: + alembic.command.upgrade(alembic_cfg, "head") + except Exception as exc: + pytest.skip(f"alembic upgrade failed — plan 03 not yet complete: {exc}") + + # Connect synchronously (sqlite3 is available in stdlib) and check tables + import sqlite3 + conn = sqlite3.connect(str(db_path)) + try: + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + actual_tables = {row[0] for row in cursor.fetchall()} + finally: + conn.close() + + missing = EXPECTED_TABLES - actual_tables + assert not missing, ( + f"Migration is missing tables: {sorted(missing)}. " + f"Tables found: {sorted(actual_tables)}" + ) + + +# --------------------------------------------------------------------------- +# Test 2: documents.user_id is nullable (D-03) +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 03") +def test_documents_user_id_nullable(tmp_path, monkeypatch): + """documents.user_id must be nullable in Phase 1 (D-03 — no auth yet).""" + try: + import alembic.command + from alembic.config import Config + except ImportError as exc: + pytest.skip(f"alembic not installed: {exc}") + + db_path = tmp_path / "test_nullable.db" + db_url = f"sqlite+aiosqlite:///{db_path}" + + monkeypatch.setenv("DATABASE_MIGRATE_URL", db_url) + + alembic_cfg = Config("alembic.ini") + alembic_cfg.set_main_option("sqlalchemy.url", db_url) + + try: + alembic.command.upgrade(alembic_cfg, "head") + except Exception as exc: + pytest.skip(f"alembic upgrade failed — plan 03 not yet complete: {exc}") + + # PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk + import sqlite3 + conn = sqlite3.connect(str(db_path)) + try: + cursor = conn.execute("PRAGMA table_info(documents)") + columns = {row[1]: {"notnull": row[3]} for row in cursor.fetchall()} + finally: + conn.close() + + assert "user_id" in columns, ( + "documents table has no user_id column — migration may not have applied" + ) + assert columns["user_id"]["notnull"] == 0, ( + "documents.user_id is NOT NULL but D-03 requires it to be nullable in Phase 1" + ) diff --git a/backend/tests/test_storage.py b/backend/tests/test_storage.py new file mode 100644 index 0000000..4292e20 --- /dev/null +++ b/backend/tests/test_storage.py @@ -0,0 +1,211 @@ +""" +Wave 0 unit tests for Plan 04 (storage layer). + +All tests are marked xfail(strict=False) because the modules they reference +(storage.base, storage.minio_backend, storage.__init__) are implemented in +Plan 04. The xfail markers will be removed once Plan 04 lands and the tests +are expected to pass. + +Requirements covered: + STORE-02 — MinIO object key schema: {user_id}/{document_id}/{uuid4()}{ext} + STORE-02 — Human filename never appears in the object key +""" +from __future__ import annotations + +import re +import pytest + + +# --------------------------------------------------------------------------- +# Test 1: object key matches STORE-02 regex +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +async def test_object_key_schema(db_session): + """STORE-02: put_object must return a key matching {user_id}/{doc_id}/{uuid4}{ext}.""" + try: + from storage.minio_backend import MinIOBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + import asyncio + from unittest.mock import MagicMock, AsyncMock + + backend = MinIOBackend.__new__(MinIOBackend) + backend._client = MagicMock() + backend._bucket = "docuvault" + # put_object is synchronous in the SDK — to_thread wraps it + backend._client.put_object = MagicMock(return_value=None) + + user_id = "11111111-1111-1111-1111-111111111111" + document_id = "22222222-2222-2222-2222-222222222222" + + key = await backend.put_object( + user_id=user_id, + document_id=document_id, + file_bytes=b"x", + extension=".pdf", + content_type="application/pdf", + ) + + pattern = re.compile( + r'^[^/]+/[^/]+/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\.[a-zA-Z0-9]+)?$' + ) + assert pattern.match(key), f"Key '{key}' does not match STORE-02 schema" + + # The middle UUID segment must NOT equal the user_id or document_id + parts = key.split("/") + assert len(parts) == 3 + uuid_with_ext = parts[2] + uuid_part = uuid_with_ext.rsplit(".", 1)[0] if "." in uuid_with_ext else uuid_with_ext + assert uuid_part != user_id, "Key UUID segment must not be the user_id" + assert uuid_part != document_id, "Key UUID segment must not be the document_id" + + # Extension must be preserved + assert key.endswith(".pdf"), f"Extension not preserved in key: '{key}'" + + +# --------------------------------------------------------------------------- +# Test 2: human filename never in object key +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +async def test_filename_not_in_object_key(): + """STORE-02: The human-readable filename must never appear in the MinIO object key.""" + try: + from storage.minio_backend import MinIOBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + from unittest.mock import MagicMock + + backend = MinIOBackend.__new__(MinIOBackend) + backend._client = MagicMock() + backend._bucket = "docuvault" + backend._client.put_object = MagicMock(return_value=None) + + # The original filename is NEVER passed to put_object — only extension is used + key = await backend.put_object( + user_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + document_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + file_bytes=b"invoice content", + extension=".pdf", + content_type="application/pdf", + ) + + assert "invoice" not in key, f"Human filename fragment 'invoice' found in key: '{key}'" + assert "Q3" not in key, f"Human filename fragment 'Q3' found in key: '{key}'" + assert "secret" not in key, f"Human filename fragment 'secret' found in key: '{key}'" + + +# --------------------------------------------------------------------------- +# Test 3: StorageBackend ABC enforcement +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +def test_storage_backend_abc_methods(): + """StorageBackend is abstract — concrete subclass missing all 5 methods raises TypeError.""" + try: + from storage.base import StorageBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + class Stub(StorageBackend): + pass + + with pytest.raises(TypeError): + Stub() + + +# --------------------------------------------------------------------------- +# Test 4: factory returns MinIOBackend instance +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +def test_get_storage_backend_returns_minio(): + """get_storage_backend() factory must return a MinIOBackend instance.""" + try: + from storage import get_storage_backend + from storage.minio_backend import MinIOBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + backend = get_storage_backend() + assert isinstance(backend, MinIOBackend) + + +# --------------------------------------------------------------------------- +# Test 5: put_object wraps sync SDK call in asyncio.to_thread +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +async def test_put_object_uses_asyncio_to_thread(monkeypatch): + """MinIOBackend.put_object must delegate the blocking SDK call via asyncio.to_thread.""" + try: + from storage.minio_backend import MinIOBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + import asyncio + from unittest.mock import MagicMock, AsyncMock + + backend = MinIOBackend.__new__(MinIOBackend) + backend._client = MagicMock() + backend._bucket = "docuvault" + backend._client.put_object = MagicMock(return_value=None) + + to_thread_calls: list = [] + + original_to_thread = asyncio.to_thread + + async def tracking_to_thread(func, *args, **kwargs): + to_thread_calls.append(func) + return await original_to_thread(func, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", tracking_to_thread) + + await backend.put_object( + user_id="11111111-1111-1111-1111-111111111111", + document_id="22222222-2222-2222-2222-222222222222", + file_bytes=b"data", + extension=".txt", + content_type="text/plain", + ) + + assert len(to_thread_calls) >= 1, "asyncio.to_thread was never called" + assert backend._client.put_object in to_thread_calls, ( + "asyncio.to_thread was not called with self._client.put_object" + ) + + +# --------------------------------------------------------------------------- +# Test 6: health_check returns bool +# --------------------------------------------------------------------------- + +@pytest.mark.xfail(strict=False, reason="implemented in plan 04") +async def test_minio_backend_health_check_returns_bool(): + """MinIOBackend.health_check() returns True when bucket exists, False on exception.""" + try: + from storage.minio_backend import MinIOBackend + except ImportError as exc: + pytest.skip(f"{exc}") + + from unittest.mock import MagicMock + + # Case 1: bucket_exists returns True + backend = MinIOBackend.__new__(MinIOBackend) + backend._client = MagicMock() + backend._bucket = "docuvault" + backend._client.bucket_exists = MagicMock(return_value=True) + + result = await backend.health_check() + assert result is True, f"Expected True, got {result!r}" + + # Case 2: bucket_exists raises Exception + backend2 = MinIOBackend.__new__(MinIOBackend) + backend2._client = MagicMock() + backend2._bucket = "docuvault" + backend2._client.bucket_exists = MagicMock(side_effect=Exception("boom")) + + result2 = await backend2.health_check() + assert result2 is False, f"Expected False on exception, got {result2!r}"