PR Title Check / check-title (pull_request) Successful in 7s
Code Quality Pipeline / code-quality (pull_request) Failing after 19s
Test Python Package / unit-tests (pull_request) Successful in 47s
Test Python Package / integration-tests (pull_request) Successful in 44s
Test Python Package / coverage-report (pull_request) Successful in 11s
Introduce PostgresAdapter for dict-based row CRUD via psycopg3, including config, lazy exports, unit/integration tests with testcontainers, and an example UserTableRepository. Co-authored-by: Cursor <[email protected]>
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""Integration tests for the PostgresAdapter."""
|
|
|
|
from collections.abc import Generator
|
|
import logging
|
|
|
|
import psycopg
|
|
import pytest
|
|
|
|
from python_repositories.adapters.postgres_adapter import PostgresAdapter
|
|
from python_repositories.config import PostgresConfig
|
|
from tests.integration.postgres._containers import TEST_TABLE
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.needs_postgres]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def row() -> Generator[dict[str, object], None, None]:
|
|
"""Provide a sample row for tests."""
|
|
yield {"id": "test-id", "name": "Alice", "value": 42}
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def row_in_postgres(
|
|
raw_postgres_client: psycopg.Connection,
|
|
row: dict[str, object],
|
|
) -> Generator[tuple[str, dict[str, object]], None, None]:
|
|
"""Fixture to set up a known row in Postgres before each test."""
|
|
with raw_postgres_client.cursor() as cursor:
|
|
cursor.execute(
|
|
f"INSERT INTO {TEST_TABLE} (id, name, value) VALUES (%s, %s, %s)",
|
|
(row["id"], row["name"], row["value"]),
|
|
)
|
|
|
|
yield str(row["id"]), row
|
|
|
|
with raw_postgres_client.cursor() as cursor:
|
|
cursor.execute(f"DELETE FROM {TEST_TABLE} WHERE id = %s", (row["id"],))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def postgres_adapter(
|
|
postgres_config: PostgresConfig,
|
|
raw_postgres_client: psycopg.Connection,
|
|
) -> Generator[PostgresAdapter, None, None]:
|
|
"""Fixture to provide a connected PostgresAdapter instance."""
|
|
_ = raw_postgres_client # ensure test table exists before connect probe
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
adapter.connect()
|
|
yield adapter
|
|
adapter.disconnect()
|
|
|
|
|
|
@pytest.fixture(scope="function", autouse=True)
|
|
def clear_postgres(raw_postgres_client: psycopg.Connection) -> None:
|
|
"""Fixture to clear all rows before each test."""
|
|
with raw_postgres_client.cursor() as cursor:
|
|
cursor.execute(f"TRUNCATE TABLE {TEST_TABLE}")
|
|
|
|
|
|
def test_should_log_info_when_already_connected(
|
|
postgres_adapter: PostgresAdapter,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that PostgresAdapter logs info when connect is called while connected."""
|
|
with caplog.at_level(logging.INFO):
|
|
postgres_adapter.connect()
|
|
assert "Already connected" in caplog.text
|
|
assert "Postgres" in caplog.text
|
|
|
|
|
|
def test_should_raise_connection_error_when_unable_to_connect() -> None:
|
|
"""Test that PostgresAdapter raises ConnectionError when unable to connect."""
|
|
adapter = PostgresAdapter(
|
|
config=PostgresConfig(
|
|
uri="postgresql://invalid:5432/nodb",
|
|
table=TEST_TABLE,
|
|
)
|
|
)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.connect()
|
|
assert adapter._client is None
|
|
assert not adapter.is_connected()
|
|
|
|
|
|
def test_should_raise_connection_error_when_table_missing(
|
|
postgres_config: PostgresConfig,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ConnectionError when the table is missing."""
|
|
config = PostgresConfig(
|
|
uri=postgres_config.uri,
|
|
table="missing_table",
|
|
primary_key="id",
|
|
)
|
|
adapter = PostgresAdapter(config=config)
|
|
with pytest.raises(ConnectionError, match="does not exist"):
|
|
adapter.connect()
|
|
|
|
|
|
def test_should_log_error_on_exception_during_exit(
|
|
postgres_config: PostgresConfig,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that PostgresAdapter logs an error when an exception occurs during exit."""
|
|
try:
|
|
with PostgresAdapter(config=postgres_config) as adapter:
|
|
assert adapter.is_connected()
|
|
raise ValueError("Simulated error")
|
|
except ValueError:
|
|
pass
|
|
assert "Error while exiting context" in caplog.text
|
|
|
|
|
|
def test_should_have_context_manager(postgres_config: PostgresConfig) -> None:
|
|
"""Test that PostgresAdapter can be used as a context manager."""
|
|
with PostgresAdapter(config=postgres_config) as adapter:
|
|
assert adapter._client is not None
|
|
assert adapter._client is None
|
|
|
|
|
|
def test_should_fetch_one(
|
|
row_in_postgres: tuple[str, dict[str, object]],
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter can fetch a row by primary key."""
|
|
pk, row = row_in_postgres
|
|
fetched = postgres_adapter.fetch_one(pk)
|
|
assert fetched is not None
|
|
assert fetched["id"] == row["id"]
|
|
assert fetched["name"] == row["name"]
|
|
assert fetched["value"] == row["value"]
|
|
|
|
|
|
def test_should_fetch_none_for_missing_row(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that fetching a non-existent row returns None."""
|
|
assert postgres_adapter.fetch_one("missing-id") is None
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_fetch_one_pk(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError for an invalid primary key."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.fetch_one(None)
|
|
|
|
|
|
def test_should_raise_connection_error_on_fetch_one_when_not_connected(
|
|
postgres_config: PostgresConfig,
|
|
) -> None:
|
|
"""Test that fetch_one raises ConnectionError when not connected."""
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.fetch_one("some-id")
|
|
|
|
|
|
def test_should_upsert_row(
|
|
row: dict[str, object],
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter can insert a row."""
|
|
assert postgres_adapter.fetch_one(row["id"]) is None
|
|
postgres_adapter.upsert(row)
|
|
fetched = postgres_adapter.fetch_one(row["id"])
|
|
assert fetched is not None
|
|
assert fetched["name"] == row["name"]
|
|
|
|
|
|
def test_should_update_row(
|
|
row_in_postgres: tuple[str, dict[str, object]],
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter can update an existing row."""
|
|
pk, _ = row_in_postgres
|
|
new_row = {"id": pk, "name": "Bob", "value": 99}
|
|
postgres_adapter.upsert(new_row)
|
|
fetched = postgres_adapter.fetch_one(pk)
|
|
assert fetched is not None
|
|
assert fetched["name"] == "Bob"
|
|
assert fetched["value"] == 99
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_upsert_row(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError for invalid upsert data."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.upsert({"name": "Alice"}) # missing primary key
|
|
|
|
|
|
def test_should_raise_value_error_on_non_dict_upsert_row(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError when upsert row is not a dict."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.upsert("not-a-dict") # type: ignore[arg-type]
|
|
|
|
|
|
def test_should_raise_connection_error_on_upsert_when_not_connected(
|
|
postgres_config: PostgresConfig,
|
|
row: dict[str, object],
|
|
) -> None:
|
|
"""Test that upsert raises ConnectionError when not connected."""
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.upsert(row)
|
|
|
|
|
|
def test_should_fetch_all_rows(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter can fetch all rows."""
|
|
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
|
|
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
|
|
rows = postgres_adapter.fetch_all()
|
|
assert len(rows) == 2
|
|
ids = {row["id"] for row in rows}
|
|
assert ids == {"a", "b"}
|
|
|
|
|
|
def test_should_fetch_all_with_limit(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter respects fetch_all limit."""
|
|
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
|
|
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
|
|
rows = postgres_adapter.fetch_all(limit=1)
|
|
assert len(rows) == 1
|
|
|
|
|
|
def test_should_return_empty_list_for_empty_table(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that fetch_all returns an empty list for an empty table."""
|
|
assert postgres_adapter.fetch_all() == []
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_fetch_all_limit(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError for an invalid limit."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.fetch_all(limit=-1)
|
|
|
|
|
|
def test_should_raise_connection_error_on_fetch_all_when_not_connected(
|
|
postgres_config: PostgresConfig,
|
|
) -> None:
|
|
"""Test that fetch_all raises ConnectionError when not connected."""
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.fetch_all()
|
|
|
|
|
|
def test_should_delete_row(
|
|
row_in_postgres: tuple[str, dict[str, object]],
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that deleting a row removes it from the table."""
|
|
pk, _ = row_in_postgres
|
|
assert postgres_adapter.fetch_one(pk) is not None
|
|
postgres_adapter.delete(pk)
|
|
assert postgres_adapter.fetch_one(pk) is None
|
|
|
|
|
|
def test_should_delete_idempotently(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that deleting a missing row does not raise."""
|
|
postgres_adapter.delete("missing-id")
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_delete_pk(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError for an invalid delete pk."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.delete(None)
|
|
|
|
|
|
def test_should_raise_connection_error_on_delete_when_not_connected(
|
|
postgres_config: PostgresConfig,
|
|
) -> None:
|
|
"""Test that delete raises ConnectionError when not connected."""
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.delete("some-id")
|
|
|
|
|
|
def test_should_execute_sql(
|
|
row_in_postgres: tuple[str, dict[str, object]],
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter can execute read SQL."""
|
|
pk, _ = row_in_postgres
|
|
rows = postgres_adapter.execute(
|
|
f"SELECT * FROM {TEST_TABLE} WHERE id = %s",
|
|
(pk,),
|
|
)
|
|
assert len(rows) == 1
|
|
assert rows[0]["id"] == pk
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_execute_sql(
|
|
postgres_adapter: PostgresAdapter,
|
|
) -> None:
|
|
"""Test that PostgresAdapter raises ValueError for invalid SQL."""
|
|
with pytest.raises(ValueError):
|
|
postgres_adapter.execute("")
|
|
|
|
|
|
def test_should_raise_connection_error_on_execute_when_not_connected(
|
|
postgres_config: PostgresConfig,
|
|
) -> None:
|
|
"""Test that execute raises ConnectionError when not connected."""
|
|
adapter = PostgresAdapter(config=postgres_config)
|
|
with pytest.raises(ConnectionError):
|
|
adapter.execute("SELECT 1")
|