Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion vibetuner-docs/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ Pytest fixtures for testing vibetuner applications without external services:
|---------|---------|
| `vibetuner_client` | Async HTTP test client (httpx + ASGITransport) |
| `vibetuner_app` | Overridable FastAPI app fixture |
| `vibetuner_db` | Temporary MongoDB database with auto-teardown |
| `vibetuner_db` | Session-scoped MongoDB database, truncated per test |
| `mock_auth` | Patch authentication (login/logout without sessions) |
| `mock_tasks` | Record `enqueue` calls without Redis |
| `override_config` | Override `RuntimeConfig` values with auto-cleanup |
Expand Down
20 changes: 17 additions & 3 deletions vibetuner-docs/docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1709,10 +1709,11 @@ async def test_homepage(vibetuner_client):

Override `vibetuner_app` fixture to supply a custom FastAPI app instance.

#### `vibetuner_db` — Temporary MongoDB Database
#### `vibetuner_db` — Shared MongoDB Test Database

Creates a unique test database, initialises Beanie with all registered
models, and drops the database on teardown:
Creates a single MongoDB database for the whole test session, runs Beanie
index registration once, and **truncates every non-system collection
before and after each test** so each test starts with empty collections:

```python
async def test_create_post(vibetuner_db):
Expand All @@ -1723,6 +1724,19 @@ async def test_create_post(vibetuner_db):

Skips the test automatically if `MONGODB_URL` is not set.

**Caveats:**

- All tests in a session share the same database. Don't assert on
database-level state (existence, name, full collection drops) or on
indexes being absent.
- Indexes (including unique constraints) are built once at session
scope and persist across tests. `DuplicateKeyError` is still raised
by unique violations.
- Concurrent runs need `pytest-xdist`; the session DB name includes
the worker id (`PYTEST_XDIST_WORKER`) so workers don't collide.
- If a test crashes mid-run, the next test re-truncates on setup so
state is self-healing.

#### `mock_auth` — Authentication Mocking

Patches the auth backend so requests appear authenticated without real
Expand Down
2 changes: 1 addition & 1 deletion vibetuner-docs/docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1637,7 +1637,7 @@ auto-discovered.
|---------|-------------|
| `vibetuner_client` | Async HTTP test client with full middleware |
| `vibetuner_app` | The FastAPI app instance (override for custom apps) |
| `vibetuner_db` | Temporary MongoDB database with Beanie initialized |
| `vibetuner_db` | Session-scoped MongoDB DB; collections truncated per test |
| `mock_auth` | Mock authentication without real sessions |
| `mock_tasks` | Mock background tasks without Redis |
| `override_config` | Override RuntimeConfig values with auto-cleanup |
Expand Down
2 changes: 1 addition & 1 deletion vibetuner-docs/docs/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Vibetuner supports multiple database backends. All are optional - choose what fi
**Why:** Test vibetuner apps without external services.

- `vibetuner_client` — async HTTP client with full middleware stack
- `vibetuner_db` — temporary MongoDB with auto-teardown
- `vibetuner_db` — session-scoped MongoDB, truncated per test
- `mock_auth` — patch authentication without sessions or cookies
- `mock_tasks` — record background task enqueue calls without Redis
- `override_config` — temporarily override runtime config values
Expand Down
1 change: 1 addition & 0 deletions vibetuner-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.11",
"testcontainers[mongodb]>=4.9.0",
"rumdl>=0.1.77",
"semver>=3.0.4",
"taplo>=0.9.3",
Expand Down
119 changes: 98 additions & 21 deletions vibetuner-py/src/vibetuner/testing.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# ABOUTME: Pytest fixtures and testing utilities for vibetuner applications.
# ABOUTME: Provides test client, mock auth, mock DB, mock tasks, and config overrides.
import os
import uuid
from typing import Any, AsyncGenerator
from unittest.mock import AsyncMock, patch
Expand All @@ -8,6 +9,8 @@
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from pymongo import AsyncMongoClient
from pymongo.asynchronous.database import AsyncDatabase
from starlette.authentication import AuthCredentials

from vibetuner.frontend.oauth import WebUser
Expand Down Expand Up @@ -52,43 +55,117 @@ async def vibetuner_client(
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def vibetuner_db() -> AsyncGenerator[str, None]:
"""Temporary MongoDB test database with Beanie initialised.
async def _truncate_collections(database: AsyncDatabase) -> None:
"""Delete every document from every non-system collection."""
for name in await database.list_collection_names():
if name.startswith("system."):
continue
await database[name].delete_many({})


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def _vibetuner_db_session() -> AsyncGenerator[str, None]:
"""Session-scoped MongoDB test database with indexes built once.

Creates a uniquely-named database (namespaced per ``pytest-xdist``
worker so parallel runs don't collide), runs ``init_beanie`` with
full index registration once for the entire session, yields the DB
name, and drops the database on session teardown.

Creates a uniquely-named database, initialises Beanie with all
registered models, yields the DB name, and drops the database on
teardown. Skips the test if ``MONGODB_URL`` is not set.
The ``AsyncMongoClient`` used here lives only on the session event
loop; per-test fixtures create their own function-loop clients and
re-wire Beanie via ``init_beanie(skip_indexes=True)``. Sharing the
client across loops is impossible (pymongo binds it on first use),
so the only thing shared across tests is the database itself
(collections + indexes living server-side).

Skips the session if ``MONGODB_URL`` is not set.
"""
import vibetuner.mongo as mongo_mod
from vibetuner.config import settings
from vibetuner.mongo import _ensure_client, get_all_models, teardown_mongodb
from beanie import init_beanie

test_db_name = f"test_{uuid.uuid4().hex[:12]}"
from vibetuner.config import settings
from vibetuner.mongo import get_all_models

_ensure_client()
if mongo_mod.mongo_client is None:
if settings.mongodb_url is None:
pytest.skip("MongoDB not configured (MONGODB_URL not set)")

# Override the DB name used by all framework code
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "main")
test_db_name = f"test_{worker_id}_{uuid.uuid4().hex[:8]}"

# Make every consumer of ``settings.mongo_dbname`` (framework code,
# user code) target the session test database for the whole run.
original = type(settings).mongo_dbname
type(settings).mongo_dbname = property(lambda self: test_db_name) # type: ignore[assignment]
settings.__dict__.pop("mongo_dbname", None)

session_client: AsyncMongoClient = AsyncMongoClient(
host=str(settings.mongodb_url),
compressors=["zstd"],
)
try:
from beanie import init_beanie

await init_beanie(
database=mongo_mod.mongo_client[test_db_name],
database=session_client[test_db_name],
document_models=get_all_models(),
)
yield test_db_name
finally:
if mongo_mod.mongo_client is not None:
await mongo_mod.mongo_client.drop_database(test_db_name)
await teardown_mongodb()
type(settings).mongo_dbname = original # type: ignore[assignment]
settings.__dict__.pop("mongo_dbname", None)
try:
await session_client.drop_database(test_db_name)
finally:
await session_client.close()
type(settings).mongo_dbname = original # type: ignore[assignment]
settings.__dict__.pop("mongo_dbname", None)


@pytest_asyncio.fixture
async def vibetuner_db(_vibetuner_db_session: str) -> AsyncGenerator[str, None]:
"""MongoDB test database wired to the current event loop with clean
collections.

Backed by a session-scoped database whose indexes are built once
(see ``_vibetuner_db_session``). Each test creates its own
``AsyncMongoClient`` on the function event loop, wires Beanie to
that client with ``skip_indexes=True`` (the indexes already exist
on the shared DB), and truncates every non-system collection both
before and after the test. Truncating twice makes the fixture
self-healing if a previous test crashed before its own teardown
ran.

Skips the test if ``MONGODB_URL`` is not set.

Caveats:
- All tests in a session share the same database. Tests must not
assert on database-level state (existence, name, full collection
drops) or on indexes being absent.
- Concurrent runs need ``pytest-xdist``; the session DB name
includes the worker id so each worker is isolated.
"""
from beanie import init_beanie

import vibetuner.mongo as mongo_mod
from vibetuner.mongo import _ensure_client, get_all_models, teardown_mongodb

test_db_name = _vibetuner_db_session

_ensure_client()
if mongo_mod.mongo_client is None:
pytest.skip("MongoDB not configured (MONGODB_URL not set)")

database = mongo_mod.mongo_client[test_db_name]
await init_beanie(
database=database,
document_models=get_all_models(),
skip_indexes=True,
)
await _truncate_collections(database)

try:
yield test_db_name
finally:
try:
await _truncate_collections(database)
finally:
await teardown_mongodb()


# ---------------------------------------------------------------------------
Expand Down
Empty file.
84 changes: 84 additions & 0 deletions vibetuner-py/tests/integration/test_vibetuner_db_real.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ABOUTME: Integration test for vibetuner_db against a Dockerised MongoDB.
# ABOUTME: Verifies cross-test isolation, session DB reuse, and index persistence.
# ruff: noqa: S101
import os

import pytest
import vibetuner.mongo as mongo_mod
from beanie import Document
from pydantic import Field
from pymongo import IndexModel
from testcontainers.mongodb import MongoDbContainer
from vibetuner.config import settings
from vibetuner.mongo import get_all_models


class FixtureProbe(Document):
"""Test-only document used to exercise the vibetuner_db fixture."""

label: str = Field(...)

class Settings:
name = "fixture_probe"
indexes = [IndexModel("label", unique=True, name="fixture_probe_label_unique")]


_original_get_all_models = get_all_models


def _patched_get_all_models() -> list[type]:
base = _original_get_all_models()
if FixtureProbe not in base:
base.append(FixtureProbe)
return base


@pytest.fixture(scope="session", autouse=True)
def mongo_container():
"""Spin up a MongoDB container for the test session."""
from pydantic import MongoDsn

with MongoDbContainer("mongo:7") as container:
url = container.get_connection_url()
original_url = settings.mongodb_url
settings.mongodb_url = MongoDsn(url)
original_env = os.environ.get("MONGODB_URL")
os.environ["MONGODB_URL"] = url
# Patch model registry to include our probe.
mongo_mod.get_all_models = _patched_get_all_models
try:
yield container
finally:
mongo_mod.get_all_models = _original_get_all_models
settings.mongodb_url = original_url
if original_env is None:
os.environ.pop("MONGODB_URL", None)
else:
os.environ["MONGODB_URL"] = original_env


@pytest.mark.integration
class TestVibetunerDbIsolation:
async def test_first_test_inserts_a_row(self, vibetuner_db):
await FixtureProbe(label="from-test-1").insert()
assert (
await FixtureProbe.find_one(FixtureProbe.label == "from-test-1") is not None
)

async def test_second_test_sees_clean_state(self, vibetuner_db):
# If the per-test cleanup works, the row from test 1 is gone.
assert await FixtureProbe.find_one(FixtureProbe.label == "from-test-1") is None
await FixtureProbe(label="from-test-2").insert()
assert (
await FixtureProbe.find_one(FixtureProbe.label == "from-test-2") is not None
)

async def test_uniqueness_index_still_enforced(self, vibetuner_db):
# The unique index on ``label`` is built once at session scope and
# persists across per-test cleanups, even though per-test
# init_beanie runs with skip_indexes=True.
from pymongo.errors import DuplicateKeyError

await FixtureProbe(label="dup").insert()
with pytest.raises(DuplicateKeyError):
await FixtureProbe(label="dup").insert()
Loading
Loading