generated from coulomb/repo-seed
test: add provider postgres conformance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
82
src/user_engine/testing/postgres_provider.py
Normal file
82
src/user_engine/testing/postgres_provider.py
Normal file
@@ -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()
|
||||
107
tests/test_postgres_provider_conformance.py
Normal file
107
tests/test_postgres_provider_conformance.py
Normal file
@@ -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()
|
||||
107
workplans/USER-WP-0019-provider-backed-postgres-conformance.md
Normal file
107
workplans/USER-WP-0019-provider-backed-postgres-conformance.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user