"""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")