Skip to content

Commit cb22147

Browse files
committed
test(postgresql): add unit tests for asyncpg JSON/JSONB codec registration
1 parent 3b50cf5 commit cb22147

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Unit tests for asyncpg JSON/JSONB codec registration in PostgreSQL manager.
2+
3+
Tests cover:
4+
- _register_json_codecs registers both json and jsonb codecs
5+
- _patch_asyncpg_dialect wraps the original connect method
6+
- The patched connect invokes run_async to install codecs on each new connection
7+
8+
Note: Uses import isolation to break circular import chains (same pattern as
9+
test_database_decorator.py).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import sys
16+
from contextlib import contextmanager
17+
from typing import Generator
18+
from unittest.mock import AsyncMock, MagicMock
19+
20+
from sqlalchemy.dialects.postgresql.asyncpg import AsyncAdapt_asyncpg_dbapi
21+
22+
23+
@contextmanager
24+
def isolated_database_import() -> Generator[None, None, None]:
25+
"""Context manager to isolate circular imports for database testing."""
26+
mock_app = MagicMock()
27+
mock_importutil = MagicMock()
28+
mock_importutil.import_modules_in_pkg = lambda pkg: None
29+
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
30+
mock_mgr = MagicMock()
31+
32+
mocks = {
33+
'langbot.pkg.core.app': mock_app,
34+
'langbot.pkg.utils.importutil': mock_importutil,
35+
'langbot.pkg.persistence.mgr': mock_mgr,
36+
}
37+
38+
saved: dict[str, object] = {}
39+
for name in mocks:
40+
if name in sys.modules:
41+
saved[name] = sys.modules[name]
42+
43+
database_name = 'langbot.pkg.persistence.database'
44+
if database_name in sys.modules:
45+
saved[database_name] = sys.modules[database_name]
46+
47+
for sub in ['sqlite', 'postgresql']:
48+
full_name = f'langbot.pkg.persistence.databases.{sub}'
49+
if full_name in sys.modules:
50+
saved[full_name] = sys.modules[full_name]
51+
52+
try:
53+
for name, module in mocks.items():
54+
sys.modules[name] = module
55+
56+
sys.modules.pop(database_name, None)
57+
for sub in ['sqlite', 'postgresql']:
58+
sys.modules.pop(f'langbot.pkg.persistence.databases.{sub}', None)
59+
60+
yield
61+
finally:
62+
for name in mocks:
63+
if name in saved:
64+
sys.modules[name] = saved[name]
65+
else:
66+
sys.modules.pop(name, None)
67+
68+
if database_name in saved:
69+
sys.modules[database_name] = saved[database_name]
70+
else:
71+
sys.modules.pop(database_name, None)
72+
73+
for sub in ['sqlite', 'postgresql']:
74+
full_name = f'langbot.pkg.persistence.databases.{sub}'
75+
if full_name in saved:
76+
sys.modules[full_name] = saved[full_name]
77+
else:
78+
sys.modules.pop(full_name, None)
79+
80+
81+
def get_postgresql_module():
82+
"""Get the postgresql database module with import isolation.
83+
84+
Saves and restores the original ``AsyncAdapt_asyncpg_dbapi.connect``
85+
because the module calls ``_patch_asyncpg_dialect()`` at import time.
86+
"""
87+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
88+
try:
89+
with isolated_database_import():
90+
from langbot.pkg.persistence.databases import postgresql
91+
92+
return postgresql
93+
finally:
94+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect
95+
96+
97+
class TestRegisterJsonCodecs:
98+
"""Tests for _register_json_codecs."""
99+
100+
async def test_registers_json_and_jsonb_codecs(self):
101+
"""Test that both json and jsonb codecs are registered on the connection."""
102+
pg_module = get_postgresql_module()
103+
104+
mock_conn = MagicMock()
105+
mock_conn.set_type_codec = AsyncMock()
106+
107+
await pg_module._register_json_codecs(mock_conn)
108+
109+
assert mock_conn.set_type_codec.call_count == 2
110+
111+
first_call = mock_conn.set_type_codec.call_args_list[0]
112+
assert first_call.args[0] == 'json'
113+
assert first_call.kwargs['encoder'] == json.dumps
114+
assert first_call.kwargs['decoder'] == json.loads
115+
assert first_call.kwargs['schema'] == 'pg_catalog'
116+
assert first_call.kwargs['format'] == 'text'
117+
118+
second_call = mock_conn.set_type_codec.call_args_list[1]
119+
assert second_call.args[0] == 'jsonb'
120+
assert second_call.kwargs['encoder'] == json.dumps
121+
assert second_call.kwargs['decoder'] == json.loads
122+
assert second_call.kwargs['schema'] == 'pg_catalog'
123+
assert second_call.kwargs['format'] == 'text'
124+
125+
async def test_codec_decodes_json_string_to_dict(self):
126+
"""Test that the registered decoder correctly parses JSON strings."""
127+
pg_module = get_postgresql_module()
128+
129+
mock_conn = MagicMock()
130+
mock_conn.set_type_codec = AsyncMock()
131+
132+
await pg_module._register_json_codecs(mock_conn)
133+
134+
json_call = mock_conn.set_type_codec.call_args_list[0]
135+
decoder = json_call.kwargs['decoder']
136+
assert decoder('{"key": "value"}') == {'key': 'value'}
137+
138+
jsonb_call = mock_conn.set_type_codec.call_args_list[1]
139+
decoder_b = jsonb_call.kwargs['decoder']
140+
assert decoder_b('[1, 2, 3]') == [1, 2, 3]
141+
142+
async def test_codec_encodes_dict_to_json_string(self):
143+
"""Test that the registered encoder correctly serializes Python objects."""
144+
pg_module = get_postgresql_module()
145+
146+
mock_conn = MagicMock()
147+
mock_conn.set_type_codec = AsyncMock()
148+
149+
await pg_module._register_json_codecs(mock_conn)
150+
151+
json_call = mock_conn.set_type_codec.call_args_list[0]
152+
encoder = json_call.kwargs['encoder']
153+
assert encoder({'key': 'value'}) == '{"key": "value"}'
154+
155+
156+
class TestPatchAsyncpgDialect:
157+
"""Tests for _patch_asyncpg_dialect."""
158+
159+
def test_patch_replaces_connect_method(self):
160+
"""Test that _patch_asyncpg_dialect replaces the connect method."""
161+
pg_module = get_postgresql_module()
162+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
163+
164+
try:
165+
pg_module._patch_asyncpg_dialect()
166+
assert AsyncAdapt_asyncpg_dbapi.connect is not orig_connect
167+
finally:
168+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect
169+
170+
def test_patched_connect_calls_original_and_run_async(self):
171+
"""Test that the patched connect calls original connect and run_async."""
172+
pg_module = get_postgresql_module()
173+
174+
mock_wrapper = MagicMock()
175+
mock_wrapper.run_async = MagicMock()
176+
fake_orig_connect = MagicMock(return_value=mock_wrapper)
177+
178+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
179+
AsyncAdapt_asyncpg_dbapi.connect = fake_orig_connect
180+
181+
try:
182+
pg_module._patch_asyncpg_dialect()
183+
patched = AsyncAdapt_asyncpg_dbapi.connect
184+
185+
mock_self = MagicMock()
186+
result = patched(mock_self, 'arg1', kwarg='val')
187+
188+
fake_orig_connect.assert_called_once_with(mock_self, 'arg1', kwarg='val')
189+
mock_wrapper.run_async.assert_called_once()
190+
assert result is mock_wrapper
191+
finally:
192+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect
193+
194+
async def test_patched_connect_init_registers_codecs(self):
195+
"""Test that the run_async callback registers JSON codecs on the connection."""
196+
pg_module = get_postgresql_module()
197+
198+
mock_wrapper = MagicMock()
199+
captured_init: list = []
200+
mock_wrapper.run_async = lambda fn: captured_init.append(fn)
201+
202+
fake_orig_connect = MagicMock(return_value=mock_wrapper)
203+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
204+
AsyncAdapt_asyncpg_dbapi.connect = fake_orig_connect
205+
206+
try:
207+
pg_module._patch_asyncpg_dialect()
208+
patched = AsyncAdapt_asyncpg_dbapi.connect
209+
patched(MagicMock())
210+
211+
assert len(captured_init) == 1
212+
213+
mock_pg_conn = MagicMock()
214+
mock_pg_conn.set_type_codec = AsyncMock()
215+
await captured_init[0](mock_pg_conn)
216+
217+
assert mock_pg_conn.set_type_codec.call_count == 2
218+
finally:
219+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect
220+
221+
async def test_init_handles_conn_none_fallback(self):
222+
"""Test that _init falls back to wrapper.connection when conn is None."""
223+
pg_module = get_postgresql_module()
224+
225+
mock_pg_conn = MagicMock()
226+
mock_pg_conn.set_type_codec = AsyncMock()
227+
228+
mock_wrapper = MagicMock()
229+
captured_init: list = []
230+
mock_wrapper.run_async = lambda fn: captured_init.append(fn)
231+
232+
mock_adapt = MagicMock()
233+
mock_adapt._connection = mock_pg_conn
234+
mock_wrapper.connection = mock_adapt
235+
236+
fake_orig_connect = MagicMock(return_value=mock_wrapper)
237+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
238+
AsyncAdapt_asyncpg_dbapi.connect = fake_orig_connect
239+
240+
try:
241+
pg_module._patch_asyncpg_dialect()
242+
patched = AsyncAdapt_asyncpg_dbapi.connect
243+
patched(MagicMock())
244+
245+
assert len(captured_init) == 1
246+
await captured_init[0](None)
247+
248+
assert mock_pg_conn.set_type_codec.call_count == 2
249+
finally:
250+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect
251+
252+
def test_double_patch_still_works(self):
253+
"""Test that calling _patch_asyncpg_dialect twice still yields a working patch.
254+
255+
Double-patching stacks wrappers, so run_async fires once per layer.
256+
This verifies the stacked patch does not break.
257+
"""
258+
pg_module = get_postgresql_module()
259+
260+
mock_wrapper = MagicMock()
261+
mock_wrapper.run_async = MagicMock()
262+
263+
fake_orig_connect = MagicMock(return_value=mock_wrapper)
264+
orig_connect = AsyncAdapt_asyncpg_dbapi.connect
265+
AsyncAdapt_asyncpg_dbapi.connect = fake_orig_connect
266+
267+
try:
268+
pg_module._patch_asyncpg_dialect()
269+
first_patched = AsyncAdapt_asyncpg_dbapi.connect
270+
271+
pg_module._patch_asyncpg_dialect()
272+
second_patched = AsyncAdapt_asyncpg_dbapi.connect
273+
274+
assert first_patched is not second_patched
275+
276+
mock_wrapper.run_async.reset_mock()
277+
second_patched(MagicMock())
278+
assert mock_wrapper.run_async.called
279+
finally:
280+
AsyncAdapt_asyncpg_dbapi.connect = orig_connect

0 commit comments

Comments
 (0)