feat: init pg migration

This commit is contained in:
Alex committed 2026-04-12 00:07:24 +01:00
1 parent bd03a513e3
commit ececcb8b17
31 files changed
+2119 -42

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
"""Fixtures for integration tests that hit a live Postgres.
These tests are separate from unit tests in two ways:
1. **Directory**: They live under ``tests/integration/``, which is
ignored by the default pytest run via ``--ignore=tests/integration``
in ``pytest.ini``. The CI ``pytest.yml`` workflow therefore skips
them automatically — it runs the fast unit suite only.
2. **Marker**: Each test is marked ``@pytest.mark.integration`` so it
can be selected (or excluded) independently of its directory with
``pytest -m integration`` / ``-m "not integration"``.
Running the Postgres-backed integration tests manually::
.venv/bin/python -m pytest tests/integration/test_users_repository.py \\
--override-ini="addopts=" --no-cov
(The ``--override-ini="addopts="`` is needed because ``pytest.ini``
contains ``--ignore=tests/integration`` in ``addopts``; without the
override pytest would still skip the directory even though you named
it on the command line.)
Tests are skipped automatically if ``POSTGRES_URI`` is unset, so a
contributor who hasn't set up a local Postgres gets clean skips
instead of red tests.
"""
from __future__ import annotations
import pytest
from sqlalchemy import Engine, create_engine, text
from application.core.settings import settings
@pytest.fixture(scope="session")
def pg_engine() -> Engine:
"""Session-scoped SQLAlchemy engine for the Postgres integration DB.
Skips all Postgres-backed tests if ``POSTGRES_URI`` is unset. This
keeps CI and contributor machines without a local Postgres from
erroring out — integration tests that require the DB become
no-ops rather than failures.
"""
if not settings.POSTGRES_URI:
pytest.skip("POSTGRES_URI not set — skipping Postgres integration tests")
engine = create_engine(settings.POSTGRES_URI, future=True, pool_pre_ping=True)
try:
yield engine
finally:
engine.dispose()
@pytest.fixture
def pg_conn(pg_engine: Engine):
"""Per-test Postgres connection wrapped in a rolled-back transaction.
Uses SQLAlchemy's explicit outer-transaction pattern so every test
sees a pristine DB view without having to truncate tables. Any
nested ``begin()`` inside the repository code becomes a SAVEPOINT
under the hood.
"""
conn = pg_engine.connect()
outer = conn.begin()
try:
yield conn
finally:
outer.rollback()
conn.close()
@pytest.fixture
def pg_clean_users(pg_conn):
"""Guarantee a clean ``users`` table view for tests that need it.
The outer transaction rollback handles cleanup, but if a previous
interrupted run left rows committed, this fixture removes them
inside the transaction scope so they are invisible to the test.
``DELETE`` rather than ``TRUNCATE`` because ``TRUNCATE`` in Postgres
cannot be rolled back within a nested transaction the way
``DELETE`` can.
"""
pg_conn.execute(text("DELETE FROM users"))
return pg_conn
+179
View File
@@ -0,0 +1,179 @@
"""Integration tests for ``UsersRepository`` against a live Postgres.
These tests need:
* A running Postgres reachable via ``POSTGRES_URI``
* Alembic migration ``0001_initial`` applied
They are skipped automatically by the default ``pytest`` run because
``pytest.ini`` has ``--ignore=tests/integration`` in ``addopts``. They
are additionally marked ``@pytest.mark.integration`` so they can be
selected/excluded explicitly. Run them locally with::
.venv/bin/python -m pytest tests/integration/test_users_repository.py \\
--override-ini="addopts=" --no-cov
Covers every operation the legacy Mongo code performs on
``users_collection``: upsert + get + add/remove on the pinned and
shared_with_me JSONB arrays, plus explicit tenant-isolation checks
(a user's operations must never touch another user's data).
"""
from __future__ import annotations
import pytest
from application.storage.db.repositories.users import UsersRepository
@pytest.fixture
def repo(pg_clean_users):
return UsersRepository(pg_clean_users)
@pytest.mark.integration
class TestUpsert:
def test_upsert_creates_new_user_with_default_preferences(self, repo):
doc = repo.upsert("alice@example.com")
assert doc["user_id"] == "alice@example.com"
assert doc["agent_preferences"] == {"pinned": [], "shared_with_me": []}
assert "id" in doc
assert "_id" in doc # Mongo-compat alias
def test_upsert_is_idempotent(self, repo):
first = repo.upsert("alice@example.com")
second = repo.upsert("alice@example.com")
assert first["id"] == second["id"]
assert first["agent_preferences"] == second["agent_preferences"]
def test_upsert_preserves_existing_preferences(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
doc = repo.upsert("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
@pytest.mark.integration
class TestGet:
def test_get_returns_none_for_missing_user(self, repo):
assert repo.get("nobody@example.com") is None
def test_get_returns_dict_for_existing_user(self, repo):
repo.upsert("alice@example.com")
doc = repo.get("alice@example.com")
assert doc is not None
assert doc["user_id"] == "alice@example.com"
assert doc["agent_preferences"] == {"pinned": [], "shared_with_me": []}
@pytest.mark.integration
class TestAddPinned:
def test_add_pinned_to_new_user_creates_user(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
def test_add_pinned_is_idempotent(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
repo.add_pinned("alice@example.com", "agent-1")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
def test_add_pinned_preserves_order(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
repo.add_pinned("alice@example.com", "agent-2")
repo.add_pinned("alice@example.com", "agent-3")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-1", "agent-2", "agent-3"]
@pytest.mark.integration
class TestRemovePinned:
def test_remove_pinned_single(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
repo.add_pinned("alice@example.com", "agent-2")
repo.remove_pinned("alice@example.com", "agent-1")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-2"]
def test_remove_pinned_missing_is_noop(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-1")
repo.remove_pinned("alice@example.com", "agent-999")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
def test_remove_pinned_bulk(self, repo):
repo.upsert("alice@example.com")
for i in range(5):
repo.add_pinned("alice@example.com", f"agent-{i}")
repo.remove_pinned_bulk("alice@example.com", ["agent-1", "agent-3", "agent-999"])
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-0", "agent-2", "agent-4"]
@pytest.mark.integration
class TestSharedWithMe:
def test_add_shared_is_idempotent(self, repo):
repo.upsert("alice@example.com")
repo.add_shared("alice@example.com", "agent-x")
repo.add_shared("alice@example.com", "agent-x")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["shared_with_me"] == ["agent-x"]
def test_remove_shared_bulk(self, repo):
repo.upsert("alice@example.com")
for i in range(3):
repo.add_shared("alice@example.com", f"shared-{i}")
repo.remove_shared_bulk("alice@example.com", ["shared-0", "shared-2"])
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["shared_with_me"] == ["shared-1"]
@pytest.mark.integration
class TestRemoveAgentFromAll:
def test_removes_from_both_pinned_and_shared(self, repo):
repo.upsert("alice@example.com")
repo.add_pinned("alice@example.com", "agent-X")
repo.add_pinned("alice@example.com", "agent-keep")
repo.add_shared("alice@example.com", "agent-X")
repo.add_shared("alice@example.com", "agent-keep-2")
repo.remove_agent_from_all("alice@example.com", "agent-X")
doc = repo.get("alice@example.com")
assert doc["agent_preferences"]["pinned"] == ["agent-keep"]
assert doc["agent_preferences"]["shared_with_me"] == ["agent-keep-2"]
@pytest.mark.integration
class TestTenantIsolation:
"""Security-critical: operations on one user must never touch another's data."""
def test_add_pinned_does_not_leak_across_users(self, repo):
repo.upsert("alice@example.com")
repo.upsert("bob@example.com")
repo.add_pinned("alice@example.com", "agent-a")
repo.add_pinned("bob@example.com", "agent-b")
alice = repo.get("alice@example.com")
bob = repo.get("bob@example.com")
assert alice["agent_preferences"]["pinned"] == ["agent-a"]
assert bob["agent_preferences"]["pinned"] == ["agent-b"]
def test_remove_does_not_leak_across_users(self, repo):
repo.upsert("alice@example.com")
repo.upsert("bob@example.com")
repo.add_pinned("alice@example.com", "shared-agent-id")
repo.add_pinned("bob@example.com", "shared-agent-id")
repo.remove_pinned("alice@example.com", "shared-agent-id")
assert repo.get("alice@example.com")["agent_preferences"]["pinned"] == []
assert repo.get("bob@example.com")["agent_preferences"]["pinned"] == [
"shared-agent-id"
]