158 lines
5.8 KiB
Python
158 lines
5.8 KiB
Python
# Copyright (c) 2026 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Unit tests: PostgreSQL connector raises DatabaseQueryError on real failures.
|
|
|
|
Historical regression: ``getRecordset`` and friends used to swallow every
|
|
exception (``except Exception: log; return []``), which turned every kind of
|
|
broken query into "no rows found". That hid bugs like:
|
|
|
|
* dict passed where Postgres expected a UUID string ("can't adapt type 'dict'"),
|
|
* missing/renamed columns after an incomplete schema migration,
|
|
* dropped tables, lost connections, etc.
|
|
|
|
These tests pin the new contract: empty result sets still return ``[]`` /
|
|
``None`` (normal), but any exception inside the query path propagates as
|
|
``DatabaseQueryError`` with the table name attached. The transaction is
|
|
rolled back so the connection is usable for subsequent queries.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import psycopg2.errors
|
|
|
|
from modules.connectors.connectorDbPostgre import (
|
|
DatabaseConnector,
|
|
DatabaseQueryError,
|
|
_rollbackQuietly,
|
|
)
|
|
|
|
|
|
class DummyTable:
|
|
"""Stand-in for a Pydantic model so we can drive the connector without a real DB.
|
|
|
|
The connector reads ``model_class.__name__`` to derive the SQL table name,
|
|
so the class name itself becomes the asserted table name in tests.
|
|
"""
|
|
model_fields = {}
|
|
|
|
|
|
def _makeConnector(cursorBehavior):
|
|
"""Build a ``DatabaseConnector`` skeleton with mocked connection/cursor.
|
|
|
|
``cursorBehavior`` is a callable invoked with the cursor mock so the test
|
|
can configure ``execute``/``fetchall``/``fetchone`` per scenario.
|
|
"""
|
|
connector = DatabaseConnector.__new__(DatabaseConnector)
|
|
cursor = MagicMock()
|
|
cursorContext = MagicMock()
|
|
cursorContext.__enter__ = MagicMock(return_value=cursor)
|
|
cursorContext.__exit__ = MagicMock(return_value=False)
|
|
|
|
connection = MagicMock()
|
|
connection.cursor.return_value = cursorContext
|
|
connector.connection = connection
|
|
|
|
connector._ensureTableExists = MagicMock(return_value=True)
|
|
connector._systemTableName = "_system"
|
|
|
|
cursorBehavior(cursor)
|
|
return connector, connection, cursor
|
|
|
|
|
|
class TestGetRecordsetFailLoud:
|
|
def test_emptyResultStillReturnsList(self):
|
|
"""No rows → []; this is the normal happy path, not a failure."""
|
|
def behavior(cursor):
|
|
cursor.execute.return_value = None
|
|
cursor.fetchall.return_value = []
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
result = connector.getRecordset(DummyTable)
|
|
assert result == []
|
|
connection.rollback.assert_not_called()
|
|
|
|
def test_dictAdaptErrorRaisesDatabaseQueryError(self):
|
|
"""Reproduces the Trustee bug: passing a dict in WHERE → can't adapt → raise."""
|
|
def behavior(cursor):
|
|
cursor.execute.side_effect = psycopg2.ProgrammingError(
|
|
"can't adapt type 'dict'"
|
|
)
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
with pytest.raises(DatabaseQueryError) as excinfo:
|
|
connector.getRecordset(
|
|
DummyTable,
|
|
recordFilter={"featureInstanceId": {"id": "uuid", "featureCode": "trustee"}},
|
|
)
|
|
|
|
assert excinfo.value.table == "DummyTable"
|
|
assert "can't adapt type 'dict'" in str(excinfo.value)
|
|
assert isinstance(excinfo.value.original, psycopg2.ProgrammingError)
|
|
connection.rollback.assert_called_once()
|
|
|
|
def test_missingColumnRaisesDatabaseQueryError(self):
|
|
def behavior(cursor):
|
|
cursor.execute.side_effect = psycopg2.errors.UndefinedColumn(
|
|
'column "wat" does not exist'
|
|
)
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
with pytest.raises(DatabaseQueryError) as excinfo:
|
|
connector.getRecordset(DummyTable, recordFilter={"wat": "x"})
|
|
|
|
assert "wat" in str(excinfo.value)
|
|
connection.rollback.assert_called_once()
|
|
|
|
def test_operationalErrorRaisesDatabaseQueryError(self):
|
|
"""Connection lost mid-query is also a real failure that must propagate."""
|
|
def behavior(cursor):
|
|
cursor.execute.side_effect = psycopg2.OperationalError("connection lost")
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
with pytest.raises(DatabaseQueryError):
|
|
connector.getRecordset(DummyTable)
|
|
connection.rollback.assert_called_once()
|
|
|
|
|
|
class TestGetRecordFailLoud:
|
|
def test_recordNotFoundReturnsNone(self):
|
|
"""`fetchone()` returning None is "row missing", not an error."""
|
|
def behavior(cursor):
|
|
cursor.execute.return_value = None
|
|
cursor.fetchone.return_value = None
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
result = connector.getRecord(DummyTable, "missing-id")
|
|
assert result is None
|
|
connection.rollback.assert_not_called()
|
|
|
|
def test_queryErrorRaisesDatabaseQueryError(self):
|
|
def behavior(cursor):
|
|
cursor.execute.side_effect = psycopg2.errors.UndefinedTable(
|
|
'relation "DummyTable" does not exist'
|
|
)
|
|
connector, connection, _ = _makeConnector(behavior)
|
|
|
|
with pytest.raises(DatabaseQueryError) as excinfo:
|
|
connector.getRecord(DummyTable, "any-id")
|
|
|
|
assert excinfo.value.table == "DummyTable"
|
|
connection.rollback.assert_called_once()
|
|
|
|
|
|
class TestRollbackQuietly:
|
|
def test_rollsBackOnLiveConnection(self):
|
|
connection = MagicMock()
|
|
_rollbackQuietly(connection)
|
|
connection.rollback.assert_called_once()
|
|
|
|
def test_swallowsRollbackError(self):
|
|
"""Rollback failure must not mask the original query error."""
|
|
connection = MagicMock()
|
|
connection.rollback.side_effect = RuntimeError("rollback failed")
|
|
_rollbackQuietly(connection)
|
|
|
|
def test_noopOnNoneConnection(self):
|
|
_rollbackQuietly(None)
|