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
This commit is contained in:
Patrick Buckley
2026-03-23 11:11:40 -07:00
committed by GitHub
parent ce57df6888
commit ab1a71c86c
15 changed files with 104 additions and 133 deletions
+26
View File
@@ -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:
+75 -1
View File
@@ -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."""
-11
View File
@@ -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."""
-10
View File
@@ -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
# ---------------------------------------------------------------------------
-10
View File
@@ -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."""
+3 -9
View File
@@ -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:
-9
View File
@@ -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
# ---------------------------------------------------------------------------
-10
View File
@@ -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."""
-11
View File
@@ -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."""
-9
View File
@@ -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):
-10
View File
@@ -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):
-14
View File
@@ -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 ---------------------------------------------------
-9
View File
@@ -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):
-10
View File
@@ -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):
-10
View File
@@ -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."""