|
22 | 22 | from testcontainers.postgres import PostgresContainer |
23 | 23 |
|
24 | 24 | from basic_memory import db |
25 | | -from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager, DatabaseBackend |
| 25 | +from basic_memory.config import ( |
| 26 | + ProjectConfig, |
| 27 | + ProjectEntry, |
| 28 | + BasicMemoryConfig, |
| 29 | + ConfigManager, |
| 30 | + DatabaseBackend, |
| 31 | +) |
26 | 32 | from basic_memory.db import DatabaseType |
27 | 33 | from basic_memory.markdown import EntityParser |
28 | 34 | from basic_memory.markdown.markdown_processor import MarkdownProcessor |
@@ -83,6 +89,79 @@ def postgres_container(db_backend): |
83 | 89 | yield postgres |
84 | 90 |
|
85 | 91 |
|
| 92 | +POSTGRES_EPHEMERAL_TABLES = [ |
| 93 | + "search_vector_embeddings", |
| 94 | + "search_vector_index", |
| 95 | +] |
| 96 | + |
| 97 | + |
| 98 | +def _postgres_alembic_config(async_url: str) -> Config: |
| 99 | + """Build Alembic config for stamping the shared Postgres test schema.""" |
| 100 | + alembic_dir = Path(db.__file__).parent / "alembic" |
| 101 | + cfg = Config() |
| 102 | + cfg.set_main_option("script_location", str(alembic_dir)) |
| 103 | + cfg.set_main_option( |
| 104 | + "file_template", |
| 105 | + "%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s", |
| 106 | + ) |
| 107 | + cfg.set_main_option("timezone", "UTC") |
| 108 | + cfg.set_main_option("revision_environment", "false") |
| 109 | + cfg.set_main_option("sqlalchemy.url", async_url) |
| 110 | + return cfg |
| 111 | + |
| 112 | + |
| 113 | +def _postgres_reset_tables() -> list[str]: |
| 114 | + """Resolve the current ORM table set at reset time. |
| 115 | +
|
| 116 | + Some tests declare models after conftest import, so the list must stay dynamic. |
| 117 | + """ |
| 118 | + return [table.name for table in Base.metadata.sorted_tables] + [ |
| 119 | + "search_index", |
| 120 | + "search_vector_chunks", |
| 121 | + ] |
| 122 | + |
| 123 | + |
| 124 | +async def _reset_postgres_test_schema(engine: AsyncEngine, async_url: str) -> None: |
| 125 | + """Restore the shared Postgres schema to a clean baseline before each test.""" |
| 126 | + from basic_memory.models.search import ( |
| 127 | + CREATE_POSTGRES_SEARCH_INDEX_FTS, |
| 128 | + CREATE_POSTGRES_SEARCH_INDEX_METADATA, |
| 129 | + CREATE_POSTGRES_SEARCH_INDEX_PERMALINK, |
| 130 | + CREATE_POSTGRES_SEARCH_INDEX_TABLE, |
| 131 | + CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_INDEX, |
| 132 | + CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_TABLE, |
| 133 | + ) |
| 134 | + |
| 135 | + async with engine.begin() as conn: |
| 136 | + # Trigger: several tests intentionally drop or stub search tables to exercise recovery code. |
| 137 | + # Why: TRUNCATE is much cheaper than drop_all/create_all, but it only works when the schema exists. |
| 138 | + # Outcome: we recreate any missing core tables once, then clear rows for deterministic test setup. |
| 139 | + await conn.run_sync(Base.metadata.create_all) |
| 140 | + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE) |
| 141 | + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS) |
| 142 | + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA) |
| 143 | + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_PERMALINK) |
| 144 | + await conn.execute(CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_TABLE) |
| 145 | + await conn.execute(CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_INDEX) |
| 146 | + |
| 147 | + for table_name in POSTGRES_EPHEMERAL_TABLES: |
| 148 | + await conn.execute(text(f"DROP TABLE IF EXISTS {table_name} CASCADE")) |
| 149 | + |
| 150 | + await conn.execute( |
| 151 | + text( |
| 152 | + f"TRUNCATE TABLE {', '.join(_postgres_reset_tables())} " |
| 153 | + "RESTART IDENTITY CASCADE" |
| 154 | + ) |
| 155 | + ) |
| 156 | + |
| 157 | + alembic_version_exists = ( |
| 158 | + await conn.execute(text("SELECT to_regclass('public.alembic_version')")) |
| 159 | + ).scalar() is not None |
| 160 | + |
| 161 | + if not alembic_version_exists: |
| 162 | + command.stamp(_postgres_alembic_config(async_url), "head") |
| 163 | + |
| 164 | + |
86 | 165 | @pytest.fixture |
87 | 166 | def anyio_backend(): |
88 | 167 | return "asyncio" |
@@ -114,7 +193,7 @@ def config_home(tmp_path, monkeypatch) -> Path: |
114 | 193 | @pytest.fixture(scope="function") |
115 | 194 | def app_config(config_home, db_backend, postgres_container, monkeypatch) -> BasicMemoryConfig: |
116 | 195 | """Create test app configuration for the appropriate backend.""" |
117 | | - projects = {"test-project": str(config_home)} |
| 196 | + projects = {"test-project": ProjectEntry(path=str(config_home))} |
118 | 197 |
|
119 | 198 | # Set backend based on parameterized db_backend fixture |
120 | 199 | if db_backend == "postgres": |
@@ -229,46 +308,7 @@ async def engine_factory( |
229 | 308 | db._engine = engine |
230 | 309 | db._session_maker = session_maker |
231 | 310 |
|
232 | | - from basic_memory.models.search import ( |
233 | | - CREATE_POSTGRES_SEARCH_INDEX_TABLE, |
234 | | - CREATE_POSTGRES_SEARCH_INDEX_FTS, |
235 | | - CREATE_POSTGRES_SEARCH_INDEX_METADATA, |
236 | | - CREATE_POSTGRES_SEARCH_INDEX_PERMALINK, |
237 | | - CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_TABLE, |
238 | | - CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_INDEX, |
239 | | - ) |
240 | | - |
241 | | - # Drop and recreate all tables for test isolation |
242 | | - async with engine.begin() as conn: |
243 | | - # Must drop search_index first (has FK to project, blocks drop_all) |
244 | | - await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE")) |
245 | | - await conn.run_sync(Base.metadata.drop_all) |
246 | | - await conn.run_sync(Base.metadata.create_all) |
247 | | - # Create search_index via DDL (not ORM - uses composite PK + tsvector) |
248 | | - # asyncpg requires separate execute calls for each statement |
249 | | - await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE) |
250 | | - await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS) |
251 | | - await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA) |
252 | | - await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_PERMALINK) |
253 | | - await conn.execute(CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_TABLE) |
254 | | - await conn.execute(CREATE_POSTGRES_SEARCH_VECTOR_CHUNKS_INDEX) |
255 | | - |
256 | | - # Mark migrations as already applied for this test-created schema. |
257 | | - # |
258 | | - # Some codepaths (e.g. ensure_initialization()) invoke Alembic migrations. |
259 | | - # If we create tables via ORM directly, alembic_version is missing and migrations |
260 | | - # will try to create tables again, causing DuplicateTableError. |
261 | | - alembic_dir = Path(db.__file__).parent / "alembic" |
262 | | - cfg = Config() |
263 | | - cfg.set_main_option("script_location", str(alembic_dir)) |
264 | | - cfg.set_main_option( |
265 | | - "file_template", |
266 | | - "%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s", |
267 | | - ) |
268 | | - cfg.set_main_option("timezone", "UTC") |
269 | | - cfg.set_main_option("revision_environment", "false") |
270 | | - cfg.set_main_option("sqlalchemy.url", async_url) |
271 | | - command.stamp(cfg, "head") |
| 311 | + await _reset_postgres_test_schema(engine, async_url) |
272 | 312 |
|
273 | 313 | yield engine, session_maker |
274 | 314 |
|
|
0 commit comments