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
This commit is contained in:
curo1305
2026-05-22 09:06:55 +02:00
parent 1f675fcf1a
commit 27fa0d4631
2 changed files with 327 additions and 0 deletions
+116
View File
@@ -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"
)
+211
View File
@@ -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}"