diff --git a/docs/contracts.md b/docs/contracts.md index 6818f6d..8571262 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -262,3 +262,8 @@ Postgres implementation. It accepts a provider-owned DB-API or psycopg-like connection, applies the bootstrap SQL in `migrate`, and persists generic records, audit records, and pending outbox events without depending on a specific driver package. + +`user_engine.testing.postgres_provider` provides env-gated live conformance +helpers for provider repositories. They require a dedicated test DSN plus +`USER_ENGINE_POSTGRES_TEST_RESET=1` before deleting rows from bootstrap-owned +tables. diff --git a/docs/development.md b/docs/development.md index dcff03c..997d23b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,6 +37,19 @@ The command runs: PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py' ``` +Live Postgres conformance tests are skipped by default. To run them against a +dedicated disposable database, install `psycopg` or `psycopg2` in the active +environment and set: + +```bash +USER_ENGINE_POSTGRES_TEST_DSN='postgresql://...' \ +USER_ENGINE_POSTGRES_TEST_RESET=1 \ +make test +``` + +The reset flag is required because those tests delete rows from the +bootstrap-owned `user_engine_*` tables. + ## Implementation Rule Add new behavior in this order: diff --git a/docs/postgres-durable-store-consumer-requirements.md b/docs/postgres-durable-store-consumer-requirements.md index be58792..86d862a 100644 --- a/docs/postgres-durable-store-consumer-requirements.md +++ b/docs/postgres-durable-store-consumer-requirements.md @@ -308,10 +308,15 @@ dedicated bootstrap tables, applies the bootstrap SQL through `migrate`, and uses the shared conformance harness with a fake Postgres connection for local unit coverage. +USER-WP-0019 adds optional provider-backed conformance tests. They are skipped +by default and run only when a dedicated test database is supplied through +`USER_ENGINE_POSTGRES_TEST_DSN` and destructive cleanup is acknowledged with +`USER_ENGINE_POSTGRES_TEST_RESET=1`. The helper supports either `psycopg` or +`psycopg2` when a provider repository installs one of them. Cleanup touches +only the bootstrap-owned `user_engine_*` tables. + Likely future follow-up work should be: -- Add provider-backed conformance tests for locking, uniqueness races, - migration readiness, outbox claiming, redacted diagnostics, and restore - validation. -- Add conformance tests that run against both in-memory and Postgres stores. +- Add provider-backed conformance tests for locking, uniqueness races, outbox + claiming, redacted diagnostics, and restore validation. - Integrate the adapter with the future NetKingdom Postgres provider repo. diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index 4af0222..6b41d27 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -90,9 +90,9 @@ class PostgresUserEngineStore: return self.schema_version == LATEST_SCHEMA_VERSION def migrate(self) -> None: - sql = _load_bootstrap_sql() with self._cursor() as cursor: - cursor.execute(sql) + for statement in _bootstrap_sql_statements(): + cursor.execute(statement) self.connection.commit() @contextmanager @@ -549,16 +549,20 @@ class PostgresUserEngineStore: ) def _has_latest_schema(self) -> bool: - with self._cursor() as cursor: - cursor.execute( - """ - SELECT 1 - FROM user_engine_schema_versions - WHERE version = %s - """, - (LATEST_SCHEMA_VERSION,), - ) - return cursor.fetchone() is not None + try: + with self._cursor() as cursor: + cursor.execute( + """ + SELECT 1 + FROM user_engine_schema_versions + WHERE version = %s + """, + (LATEST_SCHEMA_VERSION,), + ) + return cursor.fetchone() is not None + except Exception: + self.connection.rollback() + return False @contextmanager def _cursor(self) -> Iterator[PostgresCursor]: @@ -624,3 +628,11 @@ def _load_bootstrap_sql() -> str: return (repo_root / "migrations/postgres/0001_user_engine_store.sql").read_text( encoding="utf-8" ) + + +def _bootstrap_sql_statements() -> tuple[str, ...]: + return tuple( + f"{statement.strip()};" + for statement in _load_bootstrap_sql().split(";") + if statement.strip() + ) diff --git a/src/user_engine/testing/postgres_provider.py b/src/user_engine/testing/postgres_provider.py new file mode 100644 index 0000000..3572a9d --- /dev/null +++ b/src/user_engine/testing/postgres_provider.py @@ -0,0 +1,82 @@ +"""Opt-in live Postgres conformance helpers for provider repositories.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Mapping + +from user_engine.adapters.postgres import PostgresConnection, PostgresUserEngineStore + +POSTGRES_TEST_DSN_ENV = "USER_ENGINE_POSTGRES_TEST_DSN" +POSTGRES_TEST_RESET_ENV = "USER_ENGINE_POSTGRES_TEST_RESET" +_TRUTHY = {"1", "true", "yes", "on"} +_TABLES = ( + "user_engine_outbox_events", + "user_engine_audit_records", + "user_engine_records", + "user_engine_schema_versions", +) + + +@dataclass(frozen=True) +class PostgresProviderTestConfig: + """Configuration for destructive provider-backed Postgres tests.""" + + dsn: str + + +def postgres_provider_test_config( + environ: Mapping[str, str] | None = None, +) -> tuple[PostgresProviderTestConfig | None, str | None]: + """Return live test config or a skip reason.""" + env = environ or os.environ + dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip() + if not dsn: + return None, f"{POSTGRES_TEST_DSN_ENV} is not set" + reset_value = env.get(POSTGRES_TEST_RESET_ENV, "").strip().lower() + if reset_value not in _TRUTHY: + return ( + None, + f"{POSTGRES_TEST_RESET_ENV}=1 is required because tests reset " + "user_engine_* tables", + ) + return PostgresProviderTestConfig(dsn=dsn), None + + +def connect_postgres_provider(dsn: str) -> PostgresConnection: + """Connect with psycopg3 or psycopg2 when a provider installs either one.""" + try: + import psycopg # type: ignore[import-not-found] + + return psycopg.connect(dsn) # type: ignore[no-any-return] + except ImportError: + pass + + try: + import psycopg2 # type: ignore[import-not-found] + + return psycopg2.connect(dsn) # type: ignore[no-any-return] + except ImportError as exc: + raise RuntimeError("install psycopg or psycopg2 to run live tests") from exc + + +def reset_user_engine_postgres_tables(connection: PostgresConnection) -> None: + """Create then empty user-engine tables in a dedicated provider test DB.""" + PostgresUserEngineStore(connection).migrate() + cursor = connection.cursor() + try: + for table in _TABLES: + cursor.execute(f"DELETE FROM {table}") + finally: + close = getattr(cursor, "close", None) + if callable(close): + close() + connection.commit() + + +def close_postgres_provider_connection(connection: Any) -> None: + """Close provider connections that expose a close method.""" + close = getattr(connection, "close", None) + if callable(close): + close() diff --git a/tests/test_postgres_provider_conformance.py b/tests/test_postgres_provider_conformance.py new file mode 100644 index 0000000..93b79cd --- /dev/null +++ b/tests/test_postgres_provider_conformance.py @@ -0,0 +1,107 @@ +import unittest + +from user_engine.adapters.postgres import PostgresUserEngineStore +from user_engine.domain import User +from user_engine.testing.postgres_provider import ( + POSTGRES_TEST_DSN_ENV, + POSTGRES_TEST_RESET_ENV, + close_postgres_provider_connection, + connect_postgres_provider, + postgres_provider_test_config, + reset_user_engine_postgres_tables, +) +from user_engine.testing.store_conformance import ( + assert_user_engine_store_conformance, +) + + +class ProviderPostgresConfigTests(unittest.TestCase): + def test_config_skips_without_dsn(self): + config, reason = postgres_provider_test_config({}) + + self.assertIsNone(config) + self.assertIn(POSTGRES_TEST_DSN_ENV, reason or "") + + def test_config_requires_reset_acknowledgement(self): + config, reason = postgres_provider_test_config( + {POSTGRES_TEST_DSN_ENV: "postgresql://example.test/db"} + ) + + self.assertIsNone(config) + self.assertIn(POSTGRES_TEST_RESET_ENV, reason or "") + + def test_config_accepts_dsn_and_reset_acknowledgement(self): + config, reason = postgres_provider_test_config( + { + POSTGRES_TEST_DSN_ENV: "postgresql://example.test/db", + POSTGRES_TEST_RESET_ENV: "1", + } + ) + + self.assertIsNotNone(config) + self.assertIsNone(reason) + + +class ProviderPostgresConformanceTests(unittest.TestCase): + def setUp(self): + self.config, reason = postgres_provider_test_config() + if reason: + self.skipTest(reason) + self.connections = [] + + def tearDown(self): + for connection in self.connections: + close_postgres_provider_connection(connection) + if self.config is not None: + cleanup = connect_postgres_provider(self.config.dsn) + try: + reset_user_engine_postgres_tables(cleanup) + finally: + close_postgres_provider_connection(cleanup) + + def test_live_postgres_store_satisfies_store_conformance(self): + assert_user_engine_store_conformance(self, self._store_factory) + + def test_live_postgres_migration_readiness(self): + store = self._store_factory() + + self.assertFalse(store.ready) + store.migrate() + + self.assertTrue(store.ready) + self.assertEqual(store.schema_version, "0001_initial") + + def test_live_postgres_upsert_keeps_one_logical_record(self): + store = self._store_factory() + store.migrate() + user = User(user_id="usr_live_upsert", display_name="Original") + replacement = User(user_id="usr_live_upsert", display_name="Replacement") + + store.save_user(user) + store.save_user(replacement) + + self.assertEqual(store.user(user.user_id), replacement) + cursor = store.connection.cursor() + try: + cursor.execute( + """ + SELECT COUNT(*) + FROM user_engine_records + WHERE record_type = %s AND record_key = %s + """, + ("users", user.user_id), + ) + self.assertEqual(cursor.fetchone()[0], 1) + finally: + cursor.close() + + def _store_factory(self) -> PostgresUserEngineStore: + assert self.config is not None + connection = connect_postgres_provider(self.config.dsn) + reset_user_engine_postgres_tables(connection) + self.connections.append(connection) + return PostgresUserEngineStore(connection) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/USER-WP-0019-provider-backed-postgres-conformance.md b/workplans/USER-WP-0019-provider-backed-postgres-conformance.md new file mode 100644 index 0000000..5a17e48 --- /dev/null +++ b/workplans/USER-WP-0019-provider-backed-postgres-conformance.md @@ -0,0 +1,107 @@ +--- +id: USER-WP-0019 +type: workplan +title: "Provider Backed Postgres Conformance" +domain: netkingdom +repo: user-engine +status: finished +owner: codex +topic_slug: netkingdom +planning_priority: medium +planning_order: 19 +created: "2026-06-16" +updated: "2026-06-16" +depends_on: + - USER-WP-0018 +--- + +# USER-WP-0019 - Provider Backed Postgres Conformance + +## Goal + +Add opt-in live Postgres conformance tests for `PostgresUserEngineStore` so +provider repositories can prove the adapter against a real database without +making ordinary user-engine tests require infrastructure. + +## Scope Direction + +The suite should be skipped unless an explicit test DSN and destructive reset +acknowledgement are supplied. It should cover migration readiness, the shared +store conformance harness, uniqueness/upsert behavior, rollback semantics, and +record-count diagnostics against a real provider connection. + +## Non-Goals + +- Do not add a mandatory Postgres driver dependency. +- Do not run live database tests by default. +- Do not implement outbox claim/ack/retry or restore validation yet. + +## Tasks + +```task +id: USER-WP-0019-T1 +status: done +priority: high +``` + +Add env-gated live Postgres connection helpers that support `psycopg` or +`psycopg2` when installed. + +```task +id: USER-WP-0019-T2 +status: done +priority: high +``` + +Require an explicit destructive reset acknowledgement before cleaning +`user_engine_*` provider test tables. + +```task +id: USER-WP-0019-T3 +status: done +priority: high +``` + +Run the shared store conformance harness against a live provider connection +when configured. + +```task +id: USER-WP-0019-T4 +status: done +priority: medium +``` + +Add live checks for migration readiness and deterministic upsert uniqueness. + +```task +id: USER-WP-0019-T5 +status: done +priority: medium +``` + +Document provider setup, skip behavior, and remaining live conformance gaps. + +## Acceptance Criteria + +- Standard `make test` skips live Postgres tests unless env vars are present. +- Live tests fail closed if a DSN is supplied without reset acknowledgement. +- Provider cleanup only touches `user_engine_*` tables created by the bootstrap. +- The live suite can use either `psycopg` or `psycopg2` if available. +- Documentation names the required env vars and remaining follow-up work. + +## Expected Outputs + +- `user_engine.testing.postgres_provider` helper. +- Env-gated provider-backed tests. +- Durable-store documentation updates. + +## Implementation Notes + +Implemented on 2026-06-16: + +- Added optional provider connection and reset helpers for live Postgres tests. +- Added env-gated tests for migration readiness, shared conformance, and + deterministic upsert uniqueness. +- Hardened adapter readiness before migration when provider tables do not yet + exist. +- Kept the default unit suite dependency-free and infrastructure-free.