Skip to content

Commit ce5f1e7

Browse files
committed
feat(test): Add and improve test coverage for postgres backend
1 parent 4105337 commit ce5f1e7

5 files changed

Lines changed: 428 additions & 0 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
2+
import ipaddress
3+
import json
4+
import pytest
5+
from psycopg.types.json import Jsonb
6+
7+
from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter, \
8+
PostgresNetworkAddressAdapter
9+
10+
11+
# Tests for PostgresJSONBAdapter
12+
def test_jsonb_adapter_supported_types():
13+
adapter = PostgresJSONBAdapter()
14+
supported = adapter.supported_types
15+
assert supported[dict] == [Jsonb]
16+
assert supported[list] == [Jsonb]
17+
18+
19+
def test_jsonb_to_database():
20+
adapter = PostgresJSONBAdapter()
21+
my_dict = {"a": 1, "b": [2, 3]}
22+
my_list = [1, "test", {"c": 4}]
23+
24+
# Test with dict
25+
result_dict = adapter.to_database(my_dict, dict)
26+
assert isinstance(result_dict, Jsonb)
27+
assert result_dict.obj == my_dict
28+
29+
# Test with list
30+
result_list = adapter.to_database(my_list, list)
31+
assert isinstance(result_list, Jsonb)
32+
assert result_list.obj == my_list
33+
34+
# Test with None
35+
assert adapter.to_database(None, dict) is None
36+
37+
38+
def test_jsonb_from_database():
39+
adapter = PostgresJSONBAdapter()
40+
my_dict = {"key": "value"}
41+
my_list = ["item1", "item2"]
42+
json_string = '{"key": "value_from_string"}'
43+
44+
# Test with value already being a dict or list
45+
assert adapter.from_database(my_dict, dict) is my_dict
46+
assert adapter.from_database(my_list, list) is my_list
47+
48+
# Test with a JSON string representation
49+
result = adapter.from_database(json_string, dict)
50+
assert isinstance(result, dict)
51+
assert result["key"] == "value_from_string"
52+
53+
# Test with None
54+
assert adapter.from_database(None, dict) is None
55+
56+
57+
# Tests for PostgresNetworkAddressAdapter
58+
def test_network_adapter_supported_types():
59+
adapter = PostgresNetworkAddressAdapter()
60+
supported = adapter.supported_types
61+
assert supported[ipaddress.IPv4Address] == [str]
62+
assert supported[ipaddress.IPv6Address] == [str]
63+
assert supported[ipaddress.IPv4Network] == [str]
64+
assert supported[ipaddress.IPv6Network] == [str]
65+
66+
67+
def test_network_adapter_supported_types_no_ipaddress(mocker):
68+
"""Test that it returns empty dict if ipaddress module is missing."""
69+
mocker.patch('builtins.__import__', side_effect=ImportError)
70+
adapter = PostgresNetworkAddressAdapter()
71+
assert adapter.supported_types == {}
72+
73+
74+
@pytest.mark.parametrize("ip_obj, expected_str", [
75+
(ipaddress.ip_address("192.168.1.1"), "192.168.1.1"),
76+
(ipaddress.ip_address("2001:db8::1"), "2001:db8::1"),
77+
(ipaddress.ip_network("192.168.0.0/24"), "192.168.0.0/24"),
78+
(ipaddress.ip_network("2001:db8::/32"), "2001:db8::/32"),
79+
])
80+
def test_network_to_database(ip_obj, expected_str):
81+
adapter = PostgresNetworkAddressAdapter()
82+
result = adapter.to_database(ip_obj, type(ip_obj))
83+
assert result == expected_str
84+
85+
86+
def test_network_to_database_none():
87+
adapter = PostgresNetworkAddressAdapter()
88+
assert adapter.to_database(None, ipaddress.IPv4Address) is None
89+
90+
91+
@pytest.mark.parametrize("ip_str, expected_type", [
92+
("192.168.1.1", ipaddress.IPv4Address),
93+
("2001:db8::1", ipaddress.IPv6Address),
94+
("192.168.0.0/24", ipaddress.IPv4Network),
95+
("2001:db8::/32", ipaddress.IPv6Network),
96+
])
97+
def test_network_from_database(ip_str, expected_type):
98+
adapter = PostgresNetworkAddressAdapter()
99+
result = adapter.from_database(ip_str, expected_type)
100+
assert isinstance(result, expected_type)
101+
assert str(result) == ip_str
102+
103+
104+
def test_network_from_database_fallback():
105+
"""Test fallback behavior for invalid network strings."""
106+
adapter = PostgresNetworkAddressAdapter()
107+
invalid_str = "not-an-ip"
108+
result = adapter.from_database(invalid_str, str)
109+
assert result == invalid_str
110+
111+
112+
def test_network_from_database_none():
113+
adapter = PostgresNetworkAddressAdapter()
114+
assert adapter.from_database(None, ipaddress.IPv4Address) is None
115+
116+
def test_network_from_database_no_ipaddress(mocker):
117+
"""Test that it returns the original value if ipaddress module is missing."""
118+
mocker.patch('builtins.__import__', side_effect=ImportError)
119+
adapter = PostgresNetworkAddressAdapter()
120+
ip_str = "192.168.1.1"
121+
assert adapter.from_database(ip_str, str) == ip_str

‎tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_transaction_backend.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,47 @@ async def test_transaction_get_cursor(async_postgres_backend):
8585
await cursor.close()
8686
finally:
8787
await async_postgres_backend._connection.set_autocommit(original_autocommit_state)
88+
89+
@pytest.mark.asyncio
90+
async def test_nested_transaction_rollback_inner(async_postgres_backend, setup_test_table):
91+
"""Test rolling back a nested async transaction while committing the outer one."""
92+
async with async_postgres_backend.transaction():
93+
# Outer transaction statement
94+
await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("outer", 40))
95+
96+
try:
97+
async with async_postgres_backend.transaction():
98+
# Inner transaction statement
99+
await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 50))
100+
raise ValueError("Rollback inner transaction")
101+
except ValueError:
102+
# Expected exception
103+
pass
104+
105+
# The outer transaction should be committed, but the inner one rolled back.
106+
rows = await async_postgres_backend.fetch_all("SELECT * FROM test_table ORDER BY name")
107+
assert len(rows) == 1
108+
assert rows[0]['name'] == "outer"
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_nested_transaction_rollback_outer(async_postgres_backend, setup_test_table):
113+
"""Test rolling back the outer async transaction after a nested one completes."""
114+
try:
115+
async with async_postgres_backend.transaction():
116+
# Outer transaction statement
117+
await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("outer", 60))
118+
119+
async with async_postgres_backend.transaction():
120+
# Inner transaction statement, this should be released to the outer savepoint
121+
await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 70))
122+
123+
# This will cause the entire transaction, including the inner "committed" part, to be rolled back.
124+
raise ValueError("Rollback outer transaction")
125+
except ValueError:
126+
# Expected exception
127+
pass
128+
129+
# Nothing should have been committed.
130+
count = await async_postgres_backend.fetch_one("SELECT COUNT(*) FROM test_table")
131+
assert count['count'] == 0
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
2+
import pytest
3+
from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
4+
5+
def test_pg_config_to_dict():
6+
"""Test that postgres-specific parameters are added to the dictionary."""
7+
config = PostgresConnectionConfig(
8+
database="test_db",
9+
sslmode="require",
10+
application_name="my_app"
11+
)
12+
13+
config_dict = config.to_dict()
14+
15+
# Test that the pg-specific parameters are present
16+
assert config_dict['sslmode'] == 'require'
17+
assert config_dict['application_name'] == 'my_app'
18+
19+
# Test that parameters with default value None are not present
20+
assert 'sslcert' not in config_dict
21+
22+
def test_pg_config_to_connection_string_with_params():
23+
"""Test connection string builds correctly with various pg parameters."""
24+
config = PostgresConnectionConfig(
25+
host="localhost",
26+
port=5432,
27+
database="test_db",
28+
username="user",
29+
sslmode="verify-full",
30+
application_name="my_app",
31+
connect_timeout=20,
32+
options={'custom_opt': 'custom_val'}
33+
)
34+
35+
conn_str = config.to_connection_string()
36+
base_url, params_str = conn_str.split('?')
37+
params = dict(p.split('=') for p in params_str.split('&'))
38+
39+
assert base_url == "postgres://user@localhost:5432/test_db" # Password is None, so it's omitted
40+
assert params['sslmode'] == 'verify-full'
41+
assert params['application_name'] == 'my_app'
42+
assert params['connect_timeout'] == '20'
43+
assert params['custom_opt'] == 'custom_val'
44+
45+
def test_pg_config_to_connection_string_no_auth():
46+
"""Test connection string with no username or password."""
47+
config = PostgresConnectionConfig(host="localhost", port=5432, database="test_db")
48+
assert config.to_connection_string() == "postgres://localhost:5432/test_db"
49+
50+
def test_validation_missing_host():
51+
"""Test validation fails if host is missing."""
52+
config = PostgresConnectionConfig(database="test_db", host=None)
53+
with pytest.raises(ValueError, match="Host is required"):
54+
config.validate()
55+
56+
def test_validation_missing_database():
57+
"""Test validation fails if database is missing."""
58+
config = PostgresConnectionConfig(host="localhost", database=None)
59+
with pytest.raises(ValueError, match="Database name is required"):
60+
config.validate()
61+
62+
@pytest.mark.parametrize("invalid_port", [0, -1, 65536])
63+
def test_validation_invalid_port(invalid_port):
64+
"""Test validation fails for invalid port numbers."""
65+
config = PostgresConnectionConfig(host="localhost", database="test_db", port=invalid_port)
66+
with pytest.raises(ValueError, match=f"Invalid port number: {invalid_port}"):
67+
config.validate()
68+
69+
def test_validation_invalid_sslmode():
70+
"""Test validation fails for an invalid sslmode."""
71+
config = PostgresConnectionConfig(host="localhost", database="test_db", sslmode="invalid_mode")
72+
with pytest.raises(ValueError, match="Invalid sslmode"):
73+
config.validate()
74+
75+
def test_validation_invalid_session_attrs():
76+
"""Test validation fails for invalid target_session_attrs."""
77+
config = PostgresConnectionConfig(
78+
host="localhost",
79+
database="test_db",
80+
target_session_attrs="invalid_attr",
81+
)
82+
with pytest.raises(ValueError, match="Invalid target_session_attrs"):
83+
config.validate()
84+
85+
@pytest.mark.parametrize("valid_session_attr", ["any", "primary", "standby", "prefer-standby", "read-write", "read-only"])
86+
def test_validation_valid_session_attrs(valid_session_attr):
87+
"""Test validation succeeds for valid target_session_attrs."""
88+
config = PostgresConnectionConfig(
89+
host="localhost",
90+
database="test_db",
91+
target_session_attrs=valid_session_attr,
92+
)
93+
assert config.validate() is True
94+
95+
def test_validation_success():
96+
"""Test a valid configuration passes validation."""
97+
config = PostgresConnectionConfig(
98+
host="localhost",
99+
database="test_db",
100+
port=5432,
101+
sslmode="require"
102+
)
103+
assert config.validate() is True
104+
105+
def test_get_pool_config_defaults():
106+
"""Test getting pool config with default values."""
107+
config = PostgresConnectionConfig(host="localhost", database="test_db")
108+
pool_config = config.get_pool_config()
109+
assert pool_config == {
110+
"min_size": 1,
111+
"max_size": 10,
112+
"timeout": 30.0,
113+
}
114+
115+
def test_get_pool_config_custom():
116+
"""Test getting pool config with custom values from the mixin."""
117+
config = PostgresConnectionConfig(
118+
host="localhost",
119+
database="test_db",
120+
pool_min_size=5,
121+
pool_max_size=20,
122+
pool_timeout=60.5,
123+
)
124+
pool_config = config.get_pool_config()
125+
assert pool_config == {
126+
"min_size": 5,
127+
"max_size": 20,
128+
"timeout": 60.5,
129+
}
130+
131+
def test_connection_string_password_behavior():
132+
"""Test that connection string correctly handles password."""
133+
# With password
134+
config = PostgresConnectionConfig(host="h", database="d", username="u", password="p", port=5432)
135+
assert config.to_connection_string() == "postgres://u:p@h:5432/d"
136+
137+
# With username but no password
138+
config = PostgresConnectionConfig(host="h", database="d", username="u", port=5432)
139+
assert config.to_connection_string() == "postgres://u@h:5432/d"
140+
141+
def test_connection_string_all_pg_params():
142+
"""Test that all pg-specific boolean and string params are in the conn string."""
143+
config = PostgresConnectionConfig(
144+
host="h", database="d", port=1,
145+
sslmode='require',
146+
sslcert='cert.pem',
147+
sslkey='key.pem',
148+
sslrootcert='root.crt',
149+
sslcrl='crl.pem',
150+
sslcompression=True,
151+
application_name='app',
152+
target_session_attrs='primary',
153+
connect_timeout=15,
154+
client_encoding='latin1',
155+
service='my_service',
156+
gssencmode='prefer',
157+
channel_binding='require'
158+
)
159+
conn_str = config.to_connection_string()
160+
161+
assert "sslmode=require" in conn_str
162+
assert "sslcert=cert.pem" in conn_str
163+
assert "sslkey=key.pem" in conn_str
164+
assert "sslrootcert=root.crt" in conn_str
165+
assert "sslcrl=crl.pem" in conn_str
166+
assert "sslcompression=True" in conn_str
167+
assert "application_name=app" in conn_str
168+
assert "target_session_attrs=primary" in conn_str
169+
assert "connect_timeout=15" in conn_str
170+
assert "client_encoding=latin1" in conn_str
171+
assert "service=my_service" in conn_str
172+
assert "gssencmode=prefer" in conn_str
173+
assert "channel_binding=require" in conn_str
174+
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
2+
from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
3+
4+
def test_format_limit_offset():
5+
"""Tests the format_limit_offset method of the PostgresDialect."""
6+
dialect = PostgresDialect(config=PostgresConnectionConfig())
7+
8+
# Test with limit only
9+
assert dialect.format_limit_offset(limit=10) == "LIMIT 10"
10+
11+
# Test with offset only
12+
assert dialect.format_limit_offset(offset=5) == "OFFSET 5"
13+
14+
# Test with both limit and offset
15+
assert dialect.format_limit_offset(limit=20, offset=10) == "LIMIT 20 OFFSET 10"
16+
17+
# Test with None values
18+
assert dialect.format_limit_offset(limit=None, offset=None) == ""
19+
20+
# Test with zero values
21+
assert dialect.format_limit_offset(limit=0, offset=0) == "LIMIT 0 OFFSET 0"
22+
23+
def test_format_identifier():
24+
"""Tests the format_identifier method."""
25+
dialect = PostgresDialect(config=PostgresConnectionConfig())
26+
27+
# Test simple identifier
28+
assert dialect.format_identifier("my_table") == '"my_table"'
29+
30+
# The dialect should handle spaces by quoting
31+
assert dialect.format_identifier("my table") == '"my table"'
32+
33+
# Test identifier with a double quote inside, which should be escaped
34+
assert dialect.format_identifier('my"table') == '"my""table"'
35+
36+
# Test an identifier that is already quoted (it should be escaped and re-quoted)
37+
assert dialect.format_identifier('"my_table"') == '"""my_table"""'
38+
39+
def test_format_string_literal():
40+
"""Tests the format_string_literal method."""
41+
dialect = PostgresDialect(config=PostgresConnectionConfig())
42+
43+
# Test a simple string
44+
assert dialect.format_string_literal("hello") == "'hello'"
45+
46+
# Test a string with a single quote
47+
assert dialect.format_string_literal("it's a test") == "'it''s a test'"

0 commit comments

Comments
 (0)