Skip to content

Commit 8f2b25f

Browse files
committed
test(core): stabilize postgres fixtures
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 052545b commit 8f2b25f

10 files changed

Lines changed: 219 additions & 74 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ See the [README.md](README.md) file for a project overview.
2222
- Run unit tests (Postgres): `just test-unit-postgres`
2323
- Run integration tests (SQLite): `just test-int-sqlite`
2424
- Run integration tests (Postgres): `just test-int-postgres`
25-
- Run impacted tests: `just testmon` (pytest-testmon)
25+
- Run impacted tests: `just testmon` (pytest-testmon; only tests affected by changed code)
2626
- Run MCP smoke test: `just test-smoke`
27-
- Fast local loop: `just fast-check`
27+
- Fast local loop: `just fast-check` (default iteration flow)
2828
- Local consistency check: `just doctor`
2929
- Generate HTML coverage: `just coverage`
3030
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
3131
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
3232
- Lint: `just lint` or `ruff check . --fix`
33-
- Type check: `just typecheck` or `uv run pyright`
34-
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
33+
- Type check: `just typecheck` or `uv run ty check src tests test-int`
34+
- Type check (pyright): `just typecheck-pyright` or `uv run pyright`
3535
- Format: `just format` or `uv run ruff format .`
3636
- Run all code checks: `just check` (runs lint, format, typecheck, test)
3737
- Create db migration: `just migration "Your migration message"`
@@ -48,10 +48,12 @@ See the [README.md](README.md) file for a project overview.
4848
### Code/Test/Verify Loop (fast path)
4949

5050
1) **Code:** make changes.
51-
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
51+
2) **Test:** `just fast-check` (lint/format/typecheck + pytest-testmon impacted tests for changed code).
5252
3) **Verify:** `just doctor` (end-to-end file ↔ DB loop in a temp project).
5353
4) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
5454

55+
Run `just test-smoke` when you specifically need the MCP smoke flow.
56+
5557
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
5658

5759
### Test Structure

justfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,20 @@ test-int-postgres:
6262
fi
6363

6464
# Run tests impacted by recent changes (requires pytest-testmon)
65+
# Pass paths or node ids after `just testmon` to limit the candidate set further.
6566
testmon *args:
66-
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon --testmon-forceselect {{args}}
67+
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon {{args}}
6768

6869
# Run MCP smoke test (fast end-to-end loop)
6970
test-smoke:
7071
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
7172

72-
# Fast local loop: lint, format, typecheck, impacted tests
73+
# Fast local loop: lint, format, typecheck, impacted tests via pytest-testmon
7374
fast-check:
7475
just fix
7576
just format
7677
just typecheck
7778
just testmon
78-
just test-smoke
7979

8080
# Reset Postgres test database (drops and recreates schema)
8181
# Useful when Alembic migration state gets out of sync during development

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ addopts = "--cov=basic_memory --cov-report term-missing"
7171
testpaths = ["tests", "test-int"]
7272
asyncio_mode = "strict"
7373
asyncio_default_fixture_loop_scope = "function"
74+
filterwarnings = [
75+
"ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning:testcontainers\\.core\\.waiting_utils",
76+
"ignore:The default datetime adapter is deprecated as of Python 3\\.12.*:DeprecationWarning:aiosqlite\\.core",
77+
"ignore:codecs\\.open\\(\\) is deprecated\\. Use open\\(\\) instead\\.:DeprecationWarning:frontmatter",
78+
"ignore:Parsing dates involving a day of month without a year specified is ambiguous.*:DeprecationWarning:dateparser\\.utils\\.strptime",
79+
]
7480
markers = [
7581
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
7682
"slow: Slow-running tests (deselect with '-m \"not slow\"')",

src/basic_memory/repository/repository.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,9 @@ async def update(self, entity_id: int, entity_data: dict[str, Any] | T) -> Optio
280280

281281
if isinstance(entity_data, dict):
282282
update_data = cast(dict[str, Any], entity_data)
283-
for key, value in update_data.items():
284-
if key in self.valid_columns:
285-
setattr(entity, key, value)
283+
for key in self.valid_columns:
284+
if key in update_data:
285+
setattr(entity, key, update_data[key])
286286

287287
elif isinstance(entity_data, self.Model):
288288
for column in self.valid_columns:

test-int/conftest.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ async def test_my_mcp_tool(mcp_server, app):
5757
import pytest_asyncio
5858
from pathlib import Path
5959
from sqlalchemy import text
60-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
60+
from sqlalchemy.ext.asyncio import (
61+
AsyncEngine,
62+
AsyncSession,
63+
async_sessionmaker,
64+
create_async_engine,
65+
)
6166
from sqlalchemy.pool import NullPool
6267
from testcontainers.postgres import PostgresContainer
6368

@@ -118,6 +123,21 @@ def postgres_container(db_backend):
118123
yield postgres
119124

120125

126+
@pytest_asyncio.fixture(autouse=True)
127+
async def cleanup_global_db_after_test() -> AsyncGenerator[None, None]:
128+
"""Close any module-level DB engine created outside fixture ownership."""
129+
yield
130+
131+
# Trigger: integration tests invoke CLI/MCP routes through the production
132+
# client fallback, bypassing this file's engine_factory fixture.
133+
# Why: those fallback engines live in basic_memory.db module state and can
134+
# otherwise leave a non-daemon aiosqlite worker alive after pytest finishes.
135+
# Outcome: every test boundary becomes a cleanup point for fallback engines.
136+
from basic_memory import db
137+
138+
await db.shutdown_db()
139+
140+
121141
POSTGRES_EPHEMERAL_TABLES = [
122142
"search_vector_embeddings",
123143
"search_vector_chunks",
@@ -182,49 +202,72 @@ async def _reset_postgres_integration_schema(engine) -> None:
182202
)
183203

184204

205+
@pytest_asyncio.fixture(scope="session", loop_scope="session")
206+
async def postgres_engine(
207+
db_backend: Literal["sqlite", "postgres"], postgres_container
208+
) -> AsyncGenerator[AsyncEngine | None, None]:
209+
"""Create the shared Postgres engine once per integration test session."""
210+
if db_backend != "postgres":
211+
yield None
212+
return
213+
214+
sync_url = _resolve_postgres_sync_url(postgres_container)
215+
async_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg")
216+
engine = create_async_engine(
217+
async_url,
218+
echo=False,
219+
poolclass=NullPool,
220+
)
221+
222+
try:
223+
yield engine
224+
finally:
225+
await engine.dispose()
226+
227+
185228
@pytest_asyncio.fixture
186229
async def engine_factory(
187230
app_config,
188231
config_manager,
189232
db_backend: Literal["sqlite", "postgres"],
190233
postgres_container,
234+
postgres_engine,
191235
tmp_path,
192236
) -> AsyncGenerator[tuple, None]:
193237
"""Create engine and session factory for the configured database backend."""
194238
from basic_memory.models.search import CREATE_SEARCH_INDEX
195239
from basic_memory import db
196240

197241
if db_backend == "postgres":
198-
# Postgres mode using testcontainers
199-
sync_url = _resolve_postgres_sync_url(postgres_container)
200-
async_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg")
242+
assert postgres_engine is not None
201243

202-
engine = create_async_engine(
203-
async_url,
204-
echo=False,
205-
poolclass=NullPool,
206-
)
244+
# Trigger: full-stack MCP/CLI tests exercise sync/indexing code that can
245+
# recover from DB errors by rolling back and opening later scoped sessions.
246+
# Why: one savepoint-backed connection is too brittle for that flow.
247+
# Outcome: reuse the engine, but reset rows/schema before each test and
248+
# let app code use normal transaction boundaries.
249+
await _reset_postgres_integration_schema(postgres_engine)
207250

208251
session_maker = async_sessionmaker(
209-
bind=engine,
252+
bind=postgres_engine,
210253
class_=AsyncSession,
211254
expire_on_commit=False,
212255
autoflush=False,
213256
)
214257

215258
# Set module-level state to prevent MCP lifespan from re-initializing
216259
# This ensures get_or_create_db() sees an existing engine and skips initialization
217-
db._engine = engine
260+
db._engine = postgres_engine
218261
db._session_maker = session_maker
219262

220-
await _reset_postgres_integration_schema(engine)
221-
222-
yield engine, session_maker
223-
224-
# Clean up module-level state
225-
await engine.dispose()
226-
db._engine = None
227-
db._session_maker = None
263+
try:
264+
yield postgres_engine, session_maker
265+
finally:
266+
# Clean up module-level state
267+
if db._engine is postgres_engine:
268+
db._engine = None
269+
if db._session_maker is session_maker:
270+
db._session_maker = None
228271

229272
else:
230273
# SQLite: Create fresh database (fast with tmp files)

test-int/semantic/conftest.py

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@
88
from __future__ import annotations
99

1010
import os
11+
from collections.abc import AsyncGenerator
1112
from dataclasses import dataclass
1213
from pathlib import Path
1314

1415
import pytest
1516
import pytest_asyncio
1617
from dotenv import load_dotenv
1718
from sqlalchemy import text
18-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
19+
from sqlalchemy.ext.asyncio import (
20+
AsyncEngine,
21+
AsyncSession,
22+
async_sessionmaker,
23+
create_async_engine,
24+
)
1925
from sqlalchemy.pool import NullPool
2026
from testcontainers.postgres import PostgresContainer
2127

@@ -152,9 +158,23 @@ async def sqlite_engine_factory(tmp_path):
152158
yield engine, session_maker
153159

154160

155-
@pytest_asyncio.fixture
156-
async def postgres_engine_factory(pgvector_container):
157-
"""Create a Postgres engine + session factory with pgvector extension."""
161+
async def _reset_postgres_semantic_schema(engine: AsyncEngine) -> None:
162+
"""Reset the semantic Postgres schema to a clean baseline."""
163+
async with engine.begin() as conn:
164+
await conn.execute(text("DROP TABLE IF EXISTS search_vector_embeddings CASCADE"))
165+
await conn.execute(text("DROP TABLE IF EXISTS search_vector_chunks CASCADE"))
166+
await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE"))
167+
await conn.run_sync(Base.metadata.drop_all)
168+
await conn.run_sync(Base.metadata.create_all)
169+
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE)
170+
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS)
171+
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA)
172+
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_PERMALINK)
173+
174+
175+
@pytest_asyncio.fixture(scope="session", loop_scope="session")
176+
async def postgres_engine(pgvector_container) -> AsyncGenerator[AsyncEngine | None, None]:
177+
"""Create the shared semantic Postgres engine once per test session."""
158178
if pgvector_container is None:
159179
yield None
160180
return
@@ -163,28 +183,35 @@ async def postgres_engine_factory(pgvector_container):
163183
async_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg")
164184

165185
engine = create_async_engine(async_url, echo=False, poolclass=NullPool)
186+
187+
try:
188+
yield engine
189+
finally:
190+
await engine.dispose()
191+
192+
193+
@pytest_asyncio.fixture
194+
async def postgres_engine_factory(postgres_engine):
195+
"""Create a Postgres session factory isolated by schema reset."""
196+
if postgres_engine is None:
197+
yield None
198+
return
199+
200+
# Trigger: semantic provider combos create and rebuild vector tables.
201+
# Why: the main suite showed savepoint-bound shared connections can get
202+
# poisoned by app-level rollback/recovery paths.
203+
# Outcome: keep the pgvector engine warm, but reset schema per test and let
204+
# repository code use normal Postgres transactions.
205+
await _reset_postgres_semantic_schema(postgres_engine)
206+
166207
session_maker = async_sessionmaker(
167-
bind=engine,
208+
bind=postgres_engine,
168209
class_=AsyncSession,
169210
expire_on_commit=False,
170211
autoflush=False,
171212
)
172213

173-
# Create schema from scratch for each test
174-
async with engine.begin() as conn:
175-
await conn.execute(text("DROP TABLE IF EXISTS search_vector_embeddings CASCADE"))
176-
await conn.execute(text("DROP TABLE IF EXISTS search_vector_chunks CASCADE"))
177-
await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE"))
178-
await conn.run_sync(Base.metadata.drop_all)
179-
await conn.run_sync(Base.metadata.create_all)
180-
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE)
181-
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS)
182-
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA)
183-
await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_PERMALINK)
184-
185-
yield engine, session_maker
186-
187-
await engine.dispose()
214+
yield postgres_engine, session_maker
188215

189216

190217
# --- Embedding provider factories ---

tests/api/v2/conftest.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@
1414

1515

1616
@pytest_asyncio.fixture
17-
async def app(test_config, engine_factory, app_config) -> FastAPI:
17+
async def app(test_config, engine_factory, app_config) -> AsyncGenerator[FastAPI, None]:
1818
"""Create FastAPI test application."""
1919
from basic_memory.api.app import app
2020

21+
previous_overrides = dict(app.dependency_overrides)
2122
app.dependency_overrides[get_app_config] = lambda: app_config
2223
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
23-
return app
24+
try:
25+
yield app
26+
finally:
27+
# Trigger: the FastAPI app is a module-level singleton shared across tests.
28+
# Why: dependency overrides that capture a per-test engine can leak into
29+
# later CLI/MCP tests and create connections outside fixture ownership.
30+
# Outcome: each API test leaves the shared app exactly as it found it.
31+
app.dependency_overrides = previous_overrides
2432

2533

2634
@pytest_asyncio.fixture

tests/cli/conftest.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,23 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
3838

3939

4040
@pytest_asyncio.fixture
41-
async def app(app_config, project_config, engine_factory, test_config, aiolib) -> FastAPI:
41+
async def app(
42+
app_config, project_config, engine_factory, test_config, aiolib
43+
) -> AsyncGenerator[FastAPI, None]:
4244
"""Create test FastAPI application."""
4345
app = fastapi_app
46+
previous_overrides = dict(app.dependency_overrides)
4447
app.dependency_overrides[get_app_config] = lambda: app_config
4548
app.dependency_overrides[get_project_config] = lambda: project_config
4649
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
47-
return app
50+
try:
51+
yield app
52+
finally:
53+
# Trigger: CLI tests share the module-level FastAPI app with API/MCP tests.
54+
# Why: leaving per-test dependency overrides installed lets later commands
55+
# talk to stale engines that no cleanup fixture owns.
56+
# Outcome: keep CLI app wiring isolated to the requesting test.
57+
app.dependency_overrides = previous_overrides
4858

4959

5060
@pytest_asyncio.fixture

0 commit comments

Comments
 (0)