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