From ab1a71c86cc0fcd2d848041f8f3c963e7f0ca1fe Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 23 Mar 2026 11:11:40 -0700 Subject: [PATCH] feat: add PostgreSQL CI integration tests (#156) * feat: add PostgreSQL CI integration tests Add --storage-backend pytest option and shared storage_backend fixture in conftest.py that creates SQLiteBackend or PostgreSQLBackend based on the flag. Migrate 13 storage test files to use shared fixture instead of local SQLiteBackend fixtures. Add test-postgres CI job with PostgreSQL 17 service container that runs the full test suite against real PostgreSQL. * fix: use TRUNCATE CASCADE for PG cleanup, wrap in try/finally TRUNCATE is faster than per-table DELETE and resets autoincrement sequences. try/except ensures reset_storage() always runs even if cleanup fails due to a corrupted connection from a failing test. * fix: document _engine coupling in PG cleanup comment --- .github/workflows/ci.yml | 26 +++++++++ tests/conftest.py | 76 ++++++++++++++++++++++++- tests/test_channel_storage.py | 11 ---- tests/test_governance_storage.py | 10 ---- tests/test_judge_storage.py | 10 ---- tests/test_mcp_server_storage.py | 12 +--- tests/test_oidc_storage.py | 9 --- tests/test_output_assessment_storage.py | 10 ---- tests/test_scheduled_tasks_storage.py | 11 ---- tests/test_services_storage.py | 9 --- tests/test_skill_resources_storage.py | 10 ---- tests/test_storage_sqlite.py | 14 ----- tests/test_structured_memory_storage.py | 9 --- tests/test_user_storage.py | 10 ---- tests/test_watch_storage.py | 10 ---- 15 files changed, 104 insertions(+), 133 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa450233..834f0da2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,32 @@ jobs: name: coverage-${{ matrix.python-version }} path: coverage.xml + test-postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: turnstone_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + - run: pip install -e ".[test,mq,postgres]" + - run: pytest tests/ -m "not live" --storage-backend=postgresql -q + env: + TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test + lock-check: runs-on: ubuntu-latest steps: diff --git a/tests/conftest.py b/tests/conftest.py index 3d539c80..2fdf5d67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,23 @@ +from __future__ import annotations + +import os from unittest.mock import MagicMock import pytest +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--storage-backend", + default="sqlite", + choices=["sqlite", "postgresql"], + help="Storage backend for integration tests (default: sqlite)", + ) + + @pytest.fixture def tmp_db(tmp_path): - """Provide a temporary SQLite storage backend.""" + """Provide a temporary SQLite storage backend (singleton registry).""" from turnstone.core.storage import init_storage, reset_storage db_path = str(tmp_path / "test.db") @@ -15,6 +27,68 @@ def tmp_db(tmp_path): reset_storage() +@pytest.fixture +def storage_backend(request, tmp_path): + """Shared storage backend fixture — respects --storage-backend flag. + + Returns a StorageBackend instance (SQLite or PostgreSQL). + Tests that use this fixture run against whichever backend CI selects. + """ + from turnstone.core.storage import init_storage, reset_storage + + backend_type = request.config.getoption("--storage-backend") + reset_storage() + + if backend_type == "postgresql": + pg_url = os.environ.get( + "TURNSTONE_TEST_PG_URL", + "postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test", + ) + backend = init_storage("postgresql", url=pg_url, run_migrations=False) + yield backend + # Truncate all tables between tests — faster than DELETE and resets + # autoincrement sequences. CASCADE handles any future FK constraints. + # NOTE: accesses backend._engine (SQLAlchemy internal) — both SQLite + # and PostgreSQL backends expose this. If a non-SQLAlchemy backend is + # ever added, this cleanup will need a protocol-level hook. + try: + import sqlalchemy as sa + + from turnstone.core.storage._schema import metadata as db_metadata + + with backend._engine.connect() as conn: + table_names = ", ".join(t.name for t in reversed(db_metadata.sorted_tables)) + conn.execute(sa.text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE")) + conn.commit() + except Exception: + pass # best-effort cleanup; reset_storage disposes engine + finally: + reset_storage() + else: + db_path = str(tmp_path / "test.db") + backend = init_storage("sqlite", path=db_path, run_migrations=False) + yield backend + reset_storage() + + +@pytest.fixture +def backend(storage_backend): + """Alias for storage_backend — used by test_storage_sqlite.py etc.""" + return storage_backend + + +@pytest.fixture +def db(storage_backend): + """Alias for storage_backend — used by domain-specific storage tests.""" + return storage_backend + + +@pytest.fixture +def storage(storage_backend): + """Alias for storage_backend — used by services/skill resource tests.""" + return storage_backend + + @pytest.fixture def mock_openai_client(): """Return a minimal mock OpenAI client.""" diff --git a/tests/test_channel_storage.py b/tests/test_channel_storage.py index 99afaa57..c8430060 100644 --- a/tests/test_channel_storage.py +++ b/tests/test_channel_storage.py @@ -2,17 +2,6 @@ from __future__ import annotations -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def db(tmp_path): - """Fresh SQLite backend for each test.""" - backend = SQLiteBackend(str(tmp_path / "test.db")) - return backend - class TestChannelUserCRUD: """Tests for channel_users table operations.""" diff --git a/tests/test_governance_storage.py b/tests/test_governance_storage.py index 0c3aaa90..8da27947 100644 --- a/tests/test_governance_storage.py +++ b/tests/test_governance_storage.py @@ -8,18 +8,8 @@ from __future__ import annotations from datetime import UTC, datetime -import pytest import sqlalchemy as sa -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - """Create a fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - - # --------------------------------------------------------------------------- # Roles # --------------------------------------------------------------------------- diff --git a/tests/test_judge_storage.py b/tests/test_judge_storage.py index c3957909..fd330622 100644 --- a/tests/test_judge_storage.py +++ b/tests/test_judge_storage.py @@ -4,16 +4,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - """Fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - def _make_verdict_kwargs(**overrides): """Build default kwargs for create_intent_verdict.""" diff --git a/tests/test_mcp_server_storage.py b/tests/test_mcp_server_storage.py index c8e55eb9..1b5e69a5 100644 --- a/tests/test_mcp_server_storage.py +++ b/tests/test_mcp_server_storage.py @@ -3,16 +3,10 @@ from __future__ import annotations import uuid +from typing import TYPE_CHECKING -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def db(tmp_path): - """Fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) +if TYPE_CHECKING: + from turnstone.core.storage._sqlite import SQLiteBackend def _make_id() -> str: diff --git a/tests/test_oidc_storage.py b/tests/test_oidc_storage.py index de97752d..4bc88502 100644 --- a/tests/test_oidc_storage.py +++ b/tests/test_oidc_storage.py @@ -6,15 +6,6 @@ import time import pytest -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - """Create a fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - - # --------------------------------------------------------------------------- # OIDC Identity CRUD # --------------------------------------------------------------------------- diff --git a/tests/test_output_assessment_storage.py b/tests/test_output_assessment_storage.py index 088ed6c7..7a964ccd 100644 --- a/tests/test_output_assessment_storage.py +++ b/tests/test_output_assessment_storage.py @@ -4,16 +4,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - """Fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - def _make_assessment_kwargs(**overrides): """Build default kwargs for record_output_assessment.""" diff --git a/tests/test_scheduled_tasks_storage.py b/tests/test_scheduled_tasks_storage.py index ae526718..8f285eed 100644 --- a/tests/test_scheduled_tasks_storage.py +++ b/tests/test_scheduled_tasks_storage.py @@ -4,17 +4,6 @@ from __future__ import annotations import time -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def db(tmp_path): - """Fresh SQLite backend for each test.""" - backend = SQLiteBackend(str(tmp_path / "test.db")) - return backend - def _make_task_kwargs(**overrides): """Build default kwargs for create_scheduled_task.""" diff --git a/tests/test_services_storage.py b/tests/test_services_storage.py index e927626e..02b2f37e 100644 --- a/tests/test_services_storage.py +++ b/tests/test_services_storage.py @@ -2,15 +2,6 @@ from __future__ import annotations -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def storage(tmp_path): - return SQLiteBackend(str(tmp_path / "test.db")) - class TestServiceRegistry: def test_register_and_list(self, storage): diff --git a/tests/test_skill_resources_storage.py b/tests/test_skill_resources_storage.py index 0e289ec5..8e0269d0 100644 --- a/tests/test_skill_resources_storage.py +++ b/tests/test_skill_resources_storage.py @@ -4,16 +4,6 @@ from __future__ import annotations import uuid -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def storage(tmp_path): - """Fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - class TestDeleteSkillResourceByPath: def test_delete_existing(self, storage): diff --git a/tests/test_storage_sqlite.py b/tests/test_storage_sqlite.py index 101f2954..f7a7613c 100644 --- a/tests/test_storage_sqlite.py +++ b/tests/test_storage_sqlite.py @@ -4,20 +4,6 @@ from __future__ import annotations from typing import Any -import pytest - -from turnstone.core.storage import init_storage, reset_storage - - -@pytest.fixture -def backend(tmp_path): - """Create a fresh SQLiteBackend for each test.""" - reset_storage() - b = init_storage("sqlite", path=str(tmp_path / "test.db"), run_migrations=False) - yield b - reset_storage() - - # -- Workstream registration --------------------------------------------------- diff --git a/tests/test_structured_memory_storage.py b/tests/test_structured_memory_storage.py index e0539ef6..f187ed7b 100644 --- a/tests/test_structured_memory_storage.py +++ b/tests/test_structured_memory_storage.py @@ -1,14 +1,5 @@ """Tests for structured memory storage backend operations.""" -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def backend(tmp_path): - return SQLiteBackend(str(tmp_path / "test.db")) - class TestCreateAndGet: def test_create_and_get_by_id(self, backend): diff --git a/tests/test_user_storage.py b/tests/test_user_storage.py index 025b788e..6bc14975 100644 --- a/tests/test_user_storage.py +++ b/tests/test_user_storage.py @@ -2,16 +2,6 @@ from __future__ import annotations -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture() -def db(tmp_path): - """Create a fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - class TestUserCRUD: def test_create_and_get(self, db): diff --git a/tests/test_watch_storage.py b/tests/test_watch_storage.py index f1b05a52..0dc188e1 100644 --- a/tests/test_watch_storage.py +++ b/tests/test_watch_storage.py @@ -2,16 +2,6 @@ from __future__ import annotations -import pytest - -from turnstone.core.storage._sqlite import SQLiteBackend - - -@pytest.fixture -def db(tmp_path): - """Fresh SQLite backend for each test.""" - return SQLiteBackend(str(tmp_path / "test.db")) - def _make_watch_kwargs(**overrides): """Build default kwargs for create_watch."""