File size: 5,834 Bytes
dc1b199
 
32c4506
 
dc1b199
 
 
 
32c4506
dc1b199
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
dc1b199
 
32c4506
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32c4506
dc1b199
 
 
 
 
 
 
 
 
 
32c4506
 
d611f45
dc1b199
32c4506
 
d611f45
32c4506
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""Shared pytest fixtures for all test modules.

All LLM and embedding calls are mocked.  Isolation tests use a LangChain FAISS
index rooted in a per-invocation temporary directory (no shared disk state).
"""

import os
import tempfile
import threading
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from typing import Any

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

# ── Force mock/test settings before importing anything from app ───────────────
os.environ.setdefault("OPENAI_API_KEY", "")
os.environ.setdefault("DEV_MODE", "true")
# Override developer .env so tests never require a live Qdrant instance.
os.environ["VECTORSTORE_BACKEND"] = "faiss"
os.environ["ENABLE_ASYNC_PIPELINE"] = "false"
os.environ["ENABLE_SPECULATIVE_EXECUTOR"] = "false"
os.environ["ENABLE_PROMPT_CACHING"] = "false"
os.environ["ENABLE_TEMPORAL_WORKFLOW"] = "false"
os.environ["SEMANTIC_CACHE_ENABLED"] = "false"
os.environ["ENABLE_JOB_QUEUE"] = "false"
os.environ["REDIS_URL"] = ""

# These must be imported after env vars are set
from app.db.database import Base, get_db  # noqa: E402
from app.embeddings.mock_client import MockEmbeddingClient  # noqa: E402
from app.main import create_app  # noqa: E402
from app.models.schemas import Chunk  # noqa: E402
from app.vectorstore.factory import reset_vectorstore  # noqa: E402

# ── In-memory SQLite test database ────────────────────────────────────────────

@pytest.fixture(scope="session")
def event_loop_policy() -> None:
    """Use the default asyncio event loop policy."""
    return None


@pytest_asyncio.fixture
async def test_db() -> AsyncGenerator[AsyncSession, None]:
    """Provide an isolated in-memory SQLite database session for each test."""
    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    factory = async_sessionmaker(engine, expire_on_commit=False)
    async with factory() as session:
        yield session

    await engine.dispose()


# ── Ephemeral FAISS vector store ──────────────────────────────────────────────

@pytest.fixture(autouse=True)
def reset_vs() -> Generator[None, None, None]:
    """Reset the vector store singleton before each test."""
    reset_vectorstore()
    yield
    reset_vectorstore()


@pytest.fixture
def in_memory_vs(tmp_path: Path) -> Any:
    """Return a FAISSVectorStore with an empty index under ``tmp_path``."""
    from langchain_community.embeddings import FakeEmbeddings

    from app.vectorstore.faiss_wrapper import FAISSVectorStore

    embedding = FakeEmbeddings(size=384)
    vs = FAISSVectorStore.__new__(FAISSVectorStore)
    vs._embedding = embedding
    vs._index_path = tmp_path / "faiss_test"
    vs._index_path.mkdir(parents=True, exist_ok=True)
    vs._lock = threading.RLock()
    vs._store = None
    return vs


# ── Mock embedding client ─────────────────────────────────────────────────────

@pytest.fixture
def mock_embedder() -> MockEmbeddingClient:
    """Return the deterministic mock embedding client."""
    return MockEmbeddingClient()


# ── Sample chunks ─────────────────────────────────────────────────────────────

@pytest.fixture
def sample_chunks_tenant_a() -> list[Chunk]:
    """FIXTURE β€” sample chunks belonging to tenant_A."""
    return [
        Chunk(
            chunk_id=f"ca-{i}",
            doc_id="doc-a",
            tenant_id="tenant_A",
            text=f"FIXTURE tenant_A document chunk number {i}.",
            section_type="paragraph",
            token_count=10,
        )
        for i in range(3)
    ]


@pytest.fixture
def sample_chunks_tenant_b() -> list[Chunk]:
    """FIXTURE β€” sample chunks belonging to tenant_B."""
    return [
        Chunk(
            chunk_id=f"cb-{i}",
            doc_id="doc-b",
            tenant_id="tenant_B",
            text=f"FIXTURE tenant_B document chunk number {i}.",
            section_type="paragraph",
            token_count=10,
        )
        for i in range(3)
    ]


# ── Temp directory ────────────────────────────────────────────────────────────

@pytest.fixture
def tmp_dir() -> Generator[Path, None, None]:
    """Provide a temporary directory that is removed after the test."""
    with tempfile.TemporaryDirectory() as d:
        yield Path(d)


# ── FastAPI test client ───────────────────────────────────────────────────────

@pytest_asyncio.fixture
async def async_client(test_db: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
    """Provide an async HTTPX client pointed at the test FastAPI app.

    The database dependency is overridden with the in-memory test session.
    """
    app = create_app()

    async def _override_db() -> AsyncGenerator[AsyncSession, None]:
        yield test_db

    app.dependency_overrides[get_db] = _override_db

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
        headers={"X-Tenant-ID": "tenant_test"},
    ) as client:
        yield client