PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 13s
Code Quality Pipeline / code-quality (pull_request) Successful in 24s
Test Python Package / integration-tests (pull_request) Successful in 29s
Test Python Package / coverage-report (pull_request) Successful in 10s
Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
7.9 KiB
Python
238 lines
7.9 KiB
Python
"""Unit tests for PostgresAdapter instantiation and injection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import psycopg
|
|
import pytest
|
|
|
|
from python_repositories.adapters.postgres_adapter import PostgresAdapter
|
|
from python_repositories.interfaces import TableRepositoryInterface
|
|
from tests.conftest import TEST_POSTGRES_CONFIG
|
|
|
|
|
|
def _mock_cursor() -> MagicMock:
|
|
mock_cursor = MagicMock()
|
|
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
|
mock_cursor.__exit__ = MagicMock(return_value=False)
|
|
return mock_cursor
|
|
|
|
|
|
def _mock_client(*, probe_raises: Exception | None = None) -> MagicMock:
|
|
mock_client = MagicMock(spec=psycopg.Connection)
|
|
mock_cursor = _mock_cursor()
|
|
if probe_raises is not None:
|
|
mock_cursor.execute.side_effect = probe_raises
|
|
mock_client.cursor.return_value = mock_cursor
|
|
return mock_client
|
|
|
|
|
|
def test_should_adhere_to_interface() -> None:
|
|
assert issubclass(PostgresAdapter, TableRepositoryInterface)
|
|
_ = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
|
|
|
|
def test_should_have_logger_when_instantiated() -> None:
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
assert hasattr(adapter, "logger")
|
|
assert adapter.logger is not None
|
|
|
|
|
|
def test_should_not_be_connected_when_instantiated() -> None:
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
assert adapter._client is None
|
|
assert not adapter.is_connected()
|
|
|
|
|
|
def test_constructs_with_injected_config_without_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("POSTGRES_URI", raising=False)
|
|
monkeypatch.delenv("POSTGRES_TABLE", raising=False)
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
assert adapter._config == TEST_POSTGRES_CONFIG
|
|
|
|
|
|
def test_constructs_with_injected_client_without_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("POSTGRES_URI", raising=False)
|
|
monkeypatch.delenv("POSTGRES_TABLE", raising=False)
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
assert adapter._client is mock_client
|
|
assert adapter._client_injected is True
|
|
|
|
|
|
def test_raises_when_client_provided_without_config() -> None:
|
|
mock_client = MagicMock(spec=psycopg.Connection)
|
|
with pytest.raises(ValueError, match="config is required"):
|
|
PostgresAdapter(client=mock_client)
|
|
|
|
|
|
def test_disconnect_does_not_close_injected_client() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
adapter.disconnect()
|
|
mock_client.close.assert_not_called()
|
|
assert adapter._client is None
|
|
|
|
|
|
class CustomEnvPostgresAdapter(PostgresAdapter):
|
|
uri_env_var_name = "CUSTOM_POSTGRES_URI"
|
|
table_env_var_name = "CUSTOM_POSTGRES_TABLE"
|
|
primary_key_env_var_name = "CUSTOM_POSTGRES_PRIMARY_KEY"
|
|
|
|
|
|
def test_subclass_custom_env_var_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CUSTOM_POSTGRES_URI", "postgresql://custom/mydb")
|
|
monkeypatch.setenv("CUSTOM_POSTGRES_TABLE", "items")
|
|
monkeypatch.setenv("CUSTOM_POSTGRES_PRIMARY_KEY", "item_id")
|
|
adapter = CustomEnvPostgresAdapter()
|
|
assert adapter._config.uri == "postgresql://custom/mydb"
|
|
assert adapter._config.table == "items"
|
|
assert adapter._config.primary_key == "item_id"
|
|
|
|
|
|
def test_connect_with_injected_client_succeeds_when_probe_ok() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
adapter.connect()
|
|
|
|
assert mock_client.cursor.return_value.execute.call_count == 2
|
|
|
|
|
|
def test_connect_with_injected_client_raises_on_probe_failure() -> None:
|
|
mock_client = _mock_client(probe_raises=psycopg.OperationalError("connection lost"))
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ConnectionError, match="Could not connect to Postgres"):
|
|
adapter.connect()
|
|
|
|
|
|
def test_connect_with_injected_client_skips_validation_when_client_cleared() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
adapter.disconnect()
|
|
|
|
adapter.connect()
|
|
|
|
mock_client.cursor.assert_not_called()
|
|
|
|
|
|
def test_connect_reconnects_when_existing_client_unhealthy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
stale_client = _mock_client(
|
|
probe_raises=psycopg.OperationalError("connection lost")
|
|
)
|
|
new_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
adapter._client = stale_client
|
|
|
|
monkeypatch.setattr("psycopg.connect", lambda *args, **kwargs: new_client)
|
|
|
|
adapter.connect()
|
|
|
|
stale_client.close.assert_called_once()
|
|
assert adapter._client is new_client
|
|
|
|
|
|
def test_connect_skips_reconnect_when_already_connected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
healthy_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
adapter._client = healthy_client
|
|
|
|
connect = MagicMock()
|
|
monkeypatch.setattr("psycopg.connect", connect)
|
|
|
|
adapter.connect()
|
|
|
|
healthy_client.close.assert_not_called()
|
|
connect.assert_not_called()
|
|
assert adapter._client is healthy_client
|
|
|
|
|
|
def test_fetch_one_raises_value_error_on_invalid_pk() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="Primary key must not be None"):
|
|
adapter.fetch_one(None)
|
|
|
|
|
|
def test_fetch_one_raises_connection_error_when_not_connected() -> None:
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
|
|
with pytest.raises(ConnectionError):
|
|
adapter.fetch_one("some-id")
|
|
|
|
|
|
def test_upsert_raises_value_error_on_invalid_row() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="Row must be a dictionary"):
|
|
adapter.upsert("not-a-dict") # type: ignore[arg-type]
|
|
|
|
|
|
def test_upsert_raises_value_error_when_primary_key_missing() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="Row must include primary key column 'id'"):
|
|
adapter.upsert({"name": "Alice"})
|
|
|
|
|
|
def test_upsert_raises_connection_error_when_not_connected() -> None:
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
|
|
with pytest.raises(ConnectionError):
|
|
adapter.upsert({"id": "alice", "name": "Alice"})
|
|
|
|
|
|
def test_fetch_all_raises_value_error_on_invalid_limit() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="Limit must be a non-negative integer"):
|
|
adapter.fetch_all(limit=-1)
|
|
|
|
|
|
def test_execute_raises_value_error_on_invalid_sql() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="SQL must be a non-empty string"):
|
|
adapter.execute("")
|
|
|
|
|
|
def test_execute_raises_value_error_on_invalid_params() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
with pytest.raises(ValueError, match="Params must be a tuple"):
|
|
adapter.execute("SELECT 1", []) # type: ignore[arg-type]
|
|
|
|
|
|
def test_upsert_with_primary_key_only_uses_do_nothing() -> None:
|
|
mock_client = _mock_client()
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
|
|
|
|
adapter.upsert({"id": "pk-only"})
|
|
|
|
mock_cursor = mock_client.cursor.return_value.__enter__.return_value
|
|
insert_call = mock_cursor.execute.call_args_list[-1]
|
|
assert insert_call[0][1] == ("pk-only",)
|
|
|
|
|
|
def test_execute_raises_connection_error_when_not_connected() -> None:
|
|
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
|
|
|
|
with pytest.raises(ConnectionError):
|
|
adapter.execute("SELECT 1")
|