omnibase_infra 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/enums/__init__.py +4 -0
- omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
- omnibase_infra/errors/__init__.py +18 -0
- omnibase_infra/errors/repository/__init__.py +78 -0
- omnibase_infra/errors/repository/errors_repository.py +424 -0
- omnibase_infra/event_bus/adapters/__init__.py +31 -0
- omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
- omnibase_infra/models/__init__.py +9 -0
- omnibase_infra/models/event_bus/__init__.py +22 -0
- omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
- omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
- omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
- omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
- omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
- omnibase_infra/models/validation/__init__.py +8 -0
- omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
- omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
- omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
- omnibase_infra/nodes/architecture_validator/constants.py +36 -0
- omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
- omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
- omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
- omnibase_infra/nodes/architecture_validator/node.py +1 -0
- omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
- omnibase_infra/nodes/node_registry_effect/node.py +20 -73
- omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
- omnibase_infra/runtime/__init__.py +11 -0
- omnibase_infra/runtime/baseline_subscriptions.py +150 -0
- omnibase_infra/runtime/db/__init__.py +73 -0
- omnibase_infra/runtime/db/models/__init__.py +41 -0
- omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
- omnibase_infra/runtime/db/postgres_repository_runtime.py +545 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
- omnibase_infra/runtime/kafka_contract_source.py +13 -5
- omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
- omnibase_infra/runtime/service_runtime_host_process.py +6 -11
- omnibase_infra/services/__init__.py +36 -0
- omnibase_infra/services/contract_publisher/__init__.py +95 -0
- omnibase_infra/services/contract_publisher/config.py +199 -0
- omnibase_infra/services/contract_publisher/errors.py +243 -0
- omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
- omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
- omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
- omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
- omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
- omnibase_infra/services/contract_publisher/service.py +617 -0
- omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
- omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
- omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
- omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
- omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
- omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
- omnibase_infra/services/observability/__init__.py +40 -0
- omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
- omnibase_infra/services/observability/agent_actions/config.py +209 -0
- omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
- omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
- omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
- omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
- omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
- omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
- omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
- omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
- omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
- omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
- omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
- omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
- omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
- omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
- omnibase_infra/validation/__init__.py +12 -0
- omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
- omnibase_infra/validation/infra_validators.py +4 -1
- omnibase_infra/validation/validation_exemptions.yaml +111 -0
- omnibase_infra/validation/validator_declarative_node.py +850 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/METADATA +2 -2
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/RECORD +88 -30
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Unit tests for WriterAgentActionsPostgres.
|
|
4
|
+
|
|
5
|
+
This module tests:
|
|
6
|
+
- Idempotency behavior (ON CONFLICT DO NOTHING / DO UPDATE)
|
|
7
|
+
- Batch write operations (empty, single, multiple items)
|
|
8
|
+
- Circuit breaker state and error handling
|
|
9
|
+
- JSON serialization for JSONB columns
|
|
10
|
+
|
|
11
|
+
All tests mock asyncpg pool - no real database required.
|
|
12
|
+
|
|
13
|
+
Related Tickets:
|
|
14
|
+
- OMN-1743: Migrate agent_actions_consumer to omnibase_infra
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from omnibase_infra.errors import (
|
|
27
|
+
InfraConnectionError,
|
|
28
|
+
InfraTimeoutError,
|
|
29
|
+
InfraUnavailableError,
|
|
30
|
+
)
|
|
31
|
+
from omnibase_infra.services.observability.agent_actions.models import (
|
|
32
|
+
ModelAgentAction,
|
|
33
|
+
ModelDetectionFailure,
|
|
34
|
+
ModelExecutionLog,
|
|
35
|
+
ModelPerformanceMetric,
|
|
36
|
+
ModelRoutingDecision,
|
|
37
|
+
ModelTransformationEvent,
|
|
38
|
+
)
|
|
39
|
+
from omnibase_infra.services.observability.agent_actions.writer_postgres import (
|
|
40
|
+
WriterAgentActionsPostgres,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
import asyncpg
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# =============================================================================
|
|
48
|
+
# Fixtures
|
|
49
|
+
# =============================================================================
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def mock_pool() -> MagicMock:
|
|
54
|
+
"""Create a mock asyncpg pool."""
|
|
55
|
+
pool = MagicMock()
|
|
56
|
+
conn = AsyncMock()
|
|
57
|
+
conn.executemany = AsyncMock()
|
|
58
|
+
|
|
59
|
+
# Make pool.acquire() return an async context manager
|
|
60
|
+
pool.acquire = MagicMock(
|
|
61
|
+
return_value=AsyncMock(
|
|
62
|
+
__aenter__=AsyncMock(return_value=conn),
|
|
63
|
+
__aexit__=AsyncMock(return_value=None),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return pool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def mock_conn(mock_pool: MagicMock) -> AsyncMock:
|
|
71
|
+
"""Get the mock connection from the pool."""
|
|
72
|
+
conn: AsyncMock = mock_pool.acquire.return_value.__aenter__.return_value
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def writer(mock_pool: MagicMock) -> WriterAgentActionsPostgres:
|
|
78
|
+
"""Create a writer with mocked pool."""
|
|
79
|
+
return WriterAgentActionsPostgres(
|
|
80
|
+
pool=mock_pool,
|
|
81
|
+
circuit_breaker_threshold=3,
|
|
82
|
+
circuit_breaker_reset_timeout=30.0,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def sample_agent_action() -> ModelAgentAction:
|
|
88
|
+
"""Create a sample agent action model."""
|
|
89
|
+
return ModelAgentAction(
|
|
90
|
+
id=uuid4(),
|
|
91
|
+
correlation_id=uuid4(),
|
|
92
|
+
agent_name="test-agent",
|
|
93
|
+
action_type="tool_call",
|
|
94
|
+
action_name="Read",
|
|
95
|
+
created_at=datetime.now(UTC),
|
|
96
|
+
status="completed",
|
|
97
|
+
duration_ms=150,
|
|
98
|
+
metadata={"file": "/test/path.py"},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture
|
|
103
|
+
def sample_routing_decision() -> ModelRoutingDecision:
|
|
104
|
+
"""Create a sample routing decision model."""
|
|
105
|
+
return ModelRoutingDecision(
|
|
106
|
+
id=uuid4(),
|
|
107
|
+
correlation_id=uuid4(),
|
|
108
|
+
selected_agent="api-architect",
|
|
109
|
+
confidence_score=0.95,
|
|
110
|
+
created_at=datetime.now(UTC),
|
|
111
|
+
alternatives=["testing", "debug"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.fixture
|
|
116
|
+
def sample_transformation_event() -> ModelTransformationEvent:
|
|
117
|
+
"""Create a sample transformation event model."""
|
|
118
|
+
return ModelTransformationEvent(
|
|
119
|
+
id=uuid4(),
|
|
120
|
+
correlation_id=uuid4(),
|
|
121
|
+
source_agent="polymorphic-agent",
|
|
122
|
+
target_agent="api-architect",
|
|
123
|
+
created_at=datetime.now(UTC),
|
|
124
|
+
trigger="Domain pattern match",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.fixture
|
|
129
|
+
def sample_performance_metric() -> ModelPerformanceMetric:
|
|
130
|
+
"""Create a sample performance metric model."""
|
|
131
|
+
return ModelPerformanceMetric(
|
|
132
|
+
id=uuid4(),
|
|
133
|
+
metric_name="routing_latency_ms",
|
|
134
|
+
metric_value=45.2,
|
|
135
|
+
created_at=datetime.now(UTC),
|
|
136
|
+
labels={"operation": "route"},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.fixture
|
|
141
|
+
def sample_detection_failure() -> ModelDetectionFailure:
|
|
142
|
+
"""Create a sample detection failure model."""
|
|
143
|
+
return ModelDetectionFailure(
|
|
144
|
+
correlation_id=uuid4(),
|
|
145
|
+
failure_reason="No matching pattern",
|
|
146
|
+
created_at=datetime.now(UTC),
|
|
147
|
+
attempted_patterns=["code-review", "testing"],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.fixture
|
|
152
|
+
def sample_execution_log() -> ModelExecutionLog:
|
|
153
|
+
"""Create a sample execution log model."""
|
|
154
|
+
now = datetime.now(UTC)
|
|
155
|
+
return ModelExecutionLog(
|
|
156
|
+
execution_id=uuid4(),
|
|
157
|
+
correlation_id=uuid4(),
|
|
158
|
+
agent_name="testing",
|
|
159
|
+
status="completed",
|
|
160
|
+
created_at=now,
|
|
161
|
+
updated_at=now,
|
|
162
|
+
duration_ms=5000,
|
|
163
|
+
exit_code=0,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# =============================================================================
|
|
168
|
+
# Batch Write Tests - Empty List
|
|
169
|
+
# =============================================================================
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestBatchWriteEmpty:
|
|
173
|
+
"""Test batch write methods with empty list input."""
|
|
174
|
+
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_write_agent_actions_empty_returns_zero(
|
|
177
|
+
self,
|
|
178
|
+
writer: WriterAgentActionsPostgres,
|
|
179
|
+
mock_conn: AsyncMock,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Writing empty list should return 0 without database call."""
|
|
182
|
+
result = await writer.write_agent_actions([])
|
|
183
|
+
|
|
184
|
+
assert result == 0
|
|
185
|
+
mock_conn.executemany.assert_not_called()
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_write_routing_decisions_empty_returns_zero(
|
|
189
|
+
self,
|
|
190
|
+
writer: WriterAgentActionsPostgres,
|
|
191
|
+
mock_conn: AsyncMock,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Writing empty routing decisions should return 0."""
|
|
194
|
+
result = await writer.write_routing_decisions([])
|
|
195
|
+
|
|
196
|
+
assert result == 0
|
|
197
|
+
mock_conn.executemany.assert_not_called()
|
|
198
|
+
|
|
199
|
+
@pytest.mark.asyncio
|
|
200
|
+
async def test_write_transformation_events_empty_returns_zero(
|
|
201
|
+
self,
|
|
202
|
+
writer: WriterAgentActionsPostgres,
|
|
203
|
+
mock_conn: AsyncMock,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Writing empty transformation events should return 0."""
|
|
206
|
+
result = await writer.write_transformation_events([])
|
|
207
|
+
|
|
208
|
+
assert result == 0
|
|
209
|
+
mock_conn.executemany.assert_not_called()
|
|
210
|
+
|
|
211
|
+
@pytest.mark.asyncio
|
|
212
|
+
async def test_write_performance_metrics_empty_returns_zero(
|
|
213
|
+
self,
|
|
214
|
+
writer: WriterAgentActionsPostgres,
|
|
215
|
+
mock_conn: AsyncMock,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Writing empty performance metrics should return 0."""
|
|
218
|
+
result = await writer.write_performance_metrics([])
|
|
219
|
+
|
|
220
|
+
assert result == 0
|
|
221
|
+
mock_conn.executemany.assert_not_called()
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_write_detection_failures_empty_returns_zero(
|
|
225
|
+
self,
|
|
226
|
+
writer: WriterAgentActionsPostgres,
|
|
227
|
+
mock_conn: AsyncMock,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Writing empty detection failures should return 0."""
|
|
230
|
+
result = await writer.write_detection_failures([])
|
|
231
|
+
|
|
232
|
+
assert result == 0
|
|
233
|
+
mock_conn.executemany.assert_not_called()
|
|
234
|
+
|
|
235
|
+
@pytest.mark.asyncio
|
|
236
|
+
async def test_write_execution_logs_empty_returns_zero(
|
|
237
|
+
self,
|
|
238
|
+
writer: WriterAgentActionsPostgres,
|
|
239
|
+
mock_conn: AsyncMock,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Writing empty execution logs should return 0."""
|
|
242
|
+
result = await writer.write_execution_logs([])
|
|
243
|
+
|
|
244
|
+
assert result == 0
|
|
245
|
+
mock_conn.executemany.assert_not_called()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# =============================================================================
|
|
249
|
+
# Batch Write Tests - Single Item
|
|
250
|
+
# =============================================================================
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestBatchWriteSingleItem:
|
|
254
|
+
"""Test batch write methods with single item."""
|
|
255
|
+
|
|
256
|
+
@pytest.mark.asyncio
|
|
257
|
+
async def test_write_agent_actions_single_item(
|
|
258
|
+
self,
|
|
259
|
+
writer: WriterAgentActionsPostgres,
|
|
260
|
+
mock_conn: AsyncMock,
|
|
261
|
+
sample_agent_action: ModelAgentAction,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Writing single agent action should call executemany with 1 item."""
|
|
264
|
+
result = await writer.write_agent_actions([sample_agent_action])
|
|
265
|
+
|
|
266
|
+
assert result == 1
|
|
267
|
+
mock_conn.executemany.assert_called_once()
|
|
268
|
+
|
|
269
|
+
# Verify SQL contains ON CONFLICT DO NOTHING
|
|
270
|
+
call_args = mock_conn.executemany.call_args
|
|
271
|
+
sql = call_args[0][0]
|
|
272
|
+
assert "ON CONFLICT (id) DO NOTHING" in sql
|
|
273
|
+
assert "agent_actions" in sql
|
|
274
|
+
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_write_routing_decisions_single_item(
|
|
277
|
+
self,
|
|
278
|
+
writer: WriterAgentActionsPostgres,
|
|
279
|
+
mock_conn: AsyncMock,
|
|
280
|
+
sample_routing_decision: ModelRoutingDecision,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Writing single routing decision should work correctly."""
|
|
283
|
+
result = await writer.write_routing_decisions([sample_routing_decision])
|
|
284
|
+
|
|
285
|
+
assert result == 1
|
|
286
|
+
mock_conn.executemany.assert_called_once()
|
|
287
|
+
|
|
288
|
+
call_args = mock_conn.executemany.call_args
|
|
289
|
+
sql = call_args[0][0]
|
|
290
|
+
assert "ON CONFLICT (id) DO NOTHING" in sql
|
|
291
|
+
assert "agent_routing_decisions" in sql
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def test_write_execution_logs_single_item_uses_do_update(
|
|
295
|
+
self,
|
|
296
|
+
writer: WriterAgentActionsPostgres,
|
|
297
|
+
mock_conn: AsyncMock,
|
|
298
|
+
sample_execution_log: ModelExecutionLog,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Execution logs should use ON CONFLICT DO UPDATE for lifecycle tracking."""
|
|
301
|
+
result = await writer.write_execution_logs([sample_execution_log])
|
|
302
|
+
|
|
303
|
+
assert result == 1
|
|
304
|
+
mock_conn.executemany.assert_called_once()
|
|
305
|
+
|
|
306
|
+
call_args = mock_conn.executemany.call_args
|
|
307
|
+
sql = call_args[0][0]
|
|
308
|
+
assert "ON CONFLICT (execution_id) DO UPDATE" in sql
|
|
309
|
+
assert "agent_execution_logs" in sql
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# =============================================================================
|
|
313
|
+
# Batch Write Tests - Multiple Items
|
|
314
|
+
# =============================================================================
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class TestBatchWriteMultipleItems:
|
|
318
|
+
"""Test batch write methods with multiple items."""
|
|
319
|
+
|
|
320
|
+
@pytest.mark.asyncio
|
|
321
|
+
async def test_write_agent_actions_multiple_items(
|
|
322
|
+
self,
|
|
323
|
+
writer: WriterAgentActionsPostgres,
|
|
324
|
+
mock_conn: AsyncMock,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Writing multiple agent actions should process all items."""
|
|
327
|
+
actions = [
|
|
328
|
+
ModelAgentAction(
|
|
329
|
+
id=uuid4(),
|
|
330
|
+
correlation_id=uuid4(),
|
|
331
|
+
agent_name=f"agent-{i}",
|
|
332
|
+
action_type="tool_call",
|
|
333
|
+
action_name="Read",
|
|
334
|
+
created_at=datetime.now(UTC),
|
|
335
|
+
)
|
|
336
|
+
for i in range(5)
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
result = await writer.write_agent_actions(actions)
|
|
340
|
+
|
|
341
|
+
assert result == 5
|
|
342
|
+
mock_conn.executemany.assert_called_once()
|
|
343
|
+
|
|
344
|
+
# Verify 5 items in the batch
|
|
345
|
+
call_args = mock_conn.executemany.call_args
|
|
346
|
+
batch_data = list(call_args[0][1])
|
|
347
|
+
assert len(batch_data) == 5
|
|
348
|
+
|
|
349
|
+
@pytest.mark.asyncio
|
|
350
|
+
async def test_write_routing_decisions_multiple_items(
|
|
351
|
+
self,
|
|
352
|
+
writer: WriterAgentActionsPostgres,
|
|
353
|
+
mock_conn: AsyncMock,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Writing multiple routing decisions should process all items."""
|
|
356
|
+
decisions = [
|
|
357
|
+
ModelRoutingDecision(
|
|
358
|
+
id=uuid4(),
|
|
359
|
+
correlation_id=uuid4(),
|
|
360
|
+
selected_agent=f"agent-{i}",
|
|
361
|
+
confidence_score=0.8 + (i * 0.01),
|
|
362
|
+
created_at=datetime.now(UTC),
|
|
363
|
+
)
|
|
364
|
+
for i in range(10)
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
result = await writer.write_routing_decisions(decisions)
|
|
368
|
+
|
|
369
|
+
assert result == 10
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# =============================================================================
|
|
373
|
+
# Idempotency Tests
|
|
374
|
+
# =============================================================================
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class TestIdempotency:
|
|
378
|
+
"""Test idempotency behavior of write methods."""
|
|
379
|
+
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_agent_actions_sql_has_on_conflict_do_nothing(
|
|
382
|
+
self,
|
|
383
|
+
writer: WriterAgentActionsPostgres,
|
|
384
|
+
mock_conn: AsyncMock,
|
|
385
|
+
sample_agent_action: ModelAgentAction,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Agent actions should use ON CONFLICT (id) DO NOTHING."""
|
|
388
|
+
await writer.write_agent_actions([sample_agent_action])
|
|
389
|
+
|
|
390
|
+
call_args = mock_conn.executemany.call_args
|
|
391
|
+
sql = call_args[0][0]
|
|
392
|
+
assert "ON CONFLICT (id) DO NOTHING" in sql
|
|
393
|
+
|
|
394
|
+
@pytest.mark.asyncio
|
|
395
|
+
async def test_detection_failures_sql_has_on_conflict_do_nothing(
|
|
396
|
+
self,
|
|
397
|
+
writer: WriterAgentActionsPostgres,
|
|
398
|
+
mock_conn: AsyncMock,
|
|
399
|
+
sample_detection_failure: ModelDetectionFailure,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Detection failures should use ON CONFLICT (correlation_id) DO NOTHING."""
|
|
402
|
+
await writer.write_detection_failures([sample_detection_failure])
|
|
403
|
+
|
|
404
|
+
call_args = mock_conn.executemany.call_args
|
|
405
|
+
sql = call_args[0][0]
|
|
406
|
+
# Detection failures use correlation_id as the idempotency key
|
|
407
|
+
assert "ON CONFLICT (correlation_id) DO NOTHING" in sql
|
|
408
|
+
|
|
409
|
+
@pytest.mark.asyncio
|
|
410
|
+
async def test_execution_logs_sql_has_on_conflict_do_update(
|
|
411
|
+
self,
|
|
412
|
+
writer: WriterAgentActionsPostgres,
|
|
413
|
+
mock_conn: AsyncMock,
|
|
414
|
+
sample_execution_log: ModelExecutionLog,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Execution logs should use ON CONFLICT DO UPDATE for lifecycle tracking."""
|
|
417
|
+
await writer.write_execution_logs([sample_execution_log])
|
|
418
|
+
|
|
419
|
+
call_args = mock_conn.executemany.call_args
|
|
420
|
+
sql = call_args[0][0]
|
|
421
|
+
assert "ON CONFLICT (execution_id) DO UPDATE" in sql
|
|
422
|
+
# Verify update fields
|
|
423
|
+
assert "status = EXCLUDED.status" in sql
|
|
424
|
+
assert "completed_at = EXCLUDED.completed_at" in sql
|
|
425
|
+
assert "duration_ms = EXCLUDED.duration_ms" in sql
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# =============================================================================
|
|
429
|
+
# Circuit Breaker Tests
|
|
430
|
+
# =============================================================================
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TestCircuitBreakerState:
|
|
434
|
+
"""Test circuit breaker state accessibility."""
|
|
435
|
+
|
|
436
|
+
def test_circuit_breaker_state_initially_closed(
|
|
437
|
+
self,
|
|
438
|
+
writer: WriterAgentActionsPostgres,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Circuit breaker should start in closed state."""
|
|
441
|
+
state = writer.get_circuit_breaker_state()
|
|
442
|
+
|
|
443
|
+
assert state["state"] == "closed"
|
|
444
|
+
assert state["failures"] == 0
|
|
445
|
+
|
|
446
|
+
def test_circuit_breaker_state_returns_dict(
|
|
447
|
+
self,
|
|
448
|
+
writer: WriterAgentActionsPostgres,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Circuit breaker state should return expected fields."""
|
|
451
|
+
state = writer.get_circuit_breaker_state()
|
|
452
|
+
|
|
453
|
+
assert "state" in state
|
|
454
|
+
assert "failures" in state
|
|
455
|
+
assert "threshold" in state
|
|
456
|
+
assert "reset_timeout_seconds" in state
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class TestCircuitBreakerErrorHandling:
|
|
460
|
+
"""Test circuit breaker error handling."""
|
|
461
|
+
|
|
462
|
+
@pytest.mark.asyncio
|
|
463
|
+
async def test_connection_error_raises_infra_connection_error(
|
|
464
|
+
self,
|
|
465
|
+
writer: WriterAgentActionsPostgres,
|
|
466
|
+
mock_conn: AsyncMock,
|
|
467
|
+
sample_agent_action: ModelAgentAction,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Connection errors should raise InfraConnectionError."""
|
|
470
|
+
import asyncpg
|
|
471
|
+
|
|
472
|
+
mock_conn.executemany.side_effect = asyncpg.PostgresConnectionError(
|
|
473
|
+
"Connection refused"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
with pytest.raises(InfraConnectionError):
|
|
477
|
+
await writer.write_agent_actions([sample_agent_action])
|
|
478
|
+
|
|
479
|
+
@pytest.mark.asyncio
|
|
480
|
+
async def test_timeout_error_raises_infra_timeout_error(
|
|
481
|
+
self,
|
|
482
|
+
writer: WriterAgentActionsPostgres,
|
|
483
|
+
mock_conn: AsyncMock,
|
|
484
|
+
sample_agent_action: ModelAgentAction,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Query timeout should raise InfraTimeoutError."""
|
|
487
|
+
import asyncpg
|
|
488
|
+
|
|
489
|
+
mock_conn.executemany.side_effect = asyncpg.QueryCanceledError(
|
|
490
|
+
"canceling statement due to statement timeout"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
with pytest.raises(InfraTimeoutError):
|
|
494
|
+
await writer.write_agent_actions([sample_agent_action])
|
|
495
|
+
|
|
496
|
+
@pytest.mark.asyncio
|
|
497
|
+
async def test_repeated_failures_open_circuit(
|
|
498
|
+
self,
|
|
499
|
+
mock_pool: MagicMock,
|
|
500
|
+
mock_conn: AsyncMock,
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Repeated failures should open the circuit breaker."""
|
|
503
|
+
import asyncpg
|
|
504
|
+
|
|
505
|
+
# Create writer with low threshold for testing
|
|
506
|
+
writer = WriterAgentActionsPostgres(
|
|
507
|
+
pool=mock_pool,
|
|
508
|
+
circuit_breaker_threshold=2,
|
|
509
|
+
circuit_breaker_reset_timeout=30.0,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
mock_conn.executemany.side_effect = asyncpg.PostgresConnectionError(
|
|
513
|
+
"Connection refused"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
action = ModelAgentAction(
|
|
517
|
+
id=uuid4(),
|
|
518
|
+
correlation_id=uuid4(),
|
|
519
|
+
agent_name="test-agent",
|
|
520
|
+
action_type="tool_call",
|
|
521
|
+
action_name="Read",
|
|
522
|
+
created_at=datetime.now(UTC),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Trigger failures to open circuit
|
|
526
|
+
for _ in range(2):
|
|
527
|
+
with pytest.raises(InfraConnectionError):
|
|
528
|
+
await writer.write_agent_actions([action])
|
|
529
|
+
|
|
530
|
+
# Circuit should be open now - next call should raise InfraUnavailableError
|
|
531
|
+
with pytest.raises(InfraUnavailableError):
|
|
532
|
+
await writer.write_agent_actions([action])
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# =============================================================================
|
|
536
|
+
# JSON Serialization Tests
|
|
537
|
+
# =============================================================================
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class TestJSONSerialization:
|
|
541
|
+
"""Test JSON serialization for JSONB columns."""
|
|
542
|
+
|
|
543
|
+
def test_serialize_json_with_dict(self) -> None:
|
|
544
|
+
"""_serialize_json should convert dict to JSON string."""
|
|
545
|
+
result = WriterAgentActionsPostgres._serialize_json({"key": "value"})
|
|
546
|
+
assert result == '{"key": "value"}'
|
|
547
|
+
|
|
548
|
+
def test_serialize_json_with_none(self) -> None:
|
|
549
|
+
"""_serialize_json should return None for None input."""
|
|
550
|
+
result = WriterAgentActionsPostgres._serialize_json(None)
|
|
551
|
+
assert result is None
|
|
552
|
+
|
|
553
|
+
def test_serialize_json_with_nested_dict(self) -> None:
|
|
554
|
+
"""_serialize_json should handle nested structures."""
|
|
555
|
+
data = {"outer": {"inner": {"deep": "value"}}}
|
|
556
|
+
result = WriterAgentActionsPostgres._serialize_json(data)
|
|
557
|
+
assert result is not None
|
|
558
|
+
assert '"outer"' in result
|
|
559
|
+
assert '"inner"' in result
|
|
560
|
+
assert '"deep"' in result
|
|
561
|
+
|
|
562
|
+
def test_serialize_list_with_strings(self) -> None:
|
|
563
|
+
"""_serialize_list should convert list to JSON string."""
|
|
564
|
+
result = WriterAgentActionsPostgres._serialize_list(["a", "b", "c"])
|
|
565
|
+
assert result == '["a", "b", "c"]'
|
|
566
|
+
|
|
567
|
+
def test_serialize_list_with_none(self) -> None:
|
|
568
|
+
"""_serialize_list should return None for None input."""
|
|
569
|
+
result = WriterAgentActionsPostgres._serialize_list(None)
|
|
570
|
+
assert result is None
|
|
571
|
+
|
|
572
|
+
@pytest.mark.asyncio
|
|
573
|
+
async def test_metadata_serialized_in_write_call(
|
|
574
|
+
self,
|
|
575
|
+
writer: WriterAgentActionsPostgres,
|
|
576
|
+
mock_conn: AsyncMock,
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Metadata dict should be serialized to JSON string in write call."""
|
|
579
|
+
action = ModelAgentAction(
|
|
580
|
+
id=uuid4(),
|
|
581
|
+
correlation_id=uuid4(),
|
|
582
|
+
agent_name="test-agent",
|
|
583
|
+
action_type="tool_call",
|
|
584
|
+
action_name="Read",
|
|
585
|
+
created_at=datetime.now(UTC),
|
|
586
|
+
metadata={"tool": "Read", "path": "/test.py"},
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
await writer.write_agent_actions([action])
|
|
590
|
+
|
|
591
|
+
call_args = mock_conn.executemany.call_args
|
|
592
|
+
batch_data = list(call_args[0][1])
|
|
593
|
+
# metadata is the last field in the INSERT tuple
|
|
594
|
+
metadata_value = batch_data[0][-1]
|
|
595
|
+
assert isinstance(metadata_value, str)
|
|
596
|
+
assert '"tool"' in metadata_value
|
|
597
|
+
assert '"Read"' in metadata_value
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# =============================================================================
|
|
601
|
+
# Correlation ID Tests
|
|
602
|
+
# =============================================================================
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class TestCorrelationId:
|
|
606
|
+
"""Test correlation ID handling."""
|
|
607
|
+
|
|
608
|
+
@pytest.mark.asyncio
|
|
609
|
+
async def test_provided_correlation_id_used(
|
|
610
|
+
self,
|
|
611
|
+
writer: WriterAgentActionsPostgres,
|
|
612
|
+
mock_conn: AsyncMock,
|
|
613
|
+
sample_agent_action: ModelAgentAction,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Provided correlation_id should be used in the operation."""
|
|
616
|
+
cid = uuid4()
|
|
617
|
+
await writer.write_agent_actions([sample_agent_action], correlation_id=cid)
|
|
618
|
+
|
|
619
|
+
# Writer should complete successfully with the provided correlation_id
|
|
620
|
+
mock_conn.executemany.assert_called_once()
|
|
621
|
+
|
|
622
|
+
@pytest.mark.asyncio
|
|
623
|
+
async def test_missing_correlation_id_generates_new(
|
|
624
|
+
self,
|
|
625
|
+
writer: WriterAgentActionsPostgres,
|
|
626
|
+
mock_conn: AsyncMock,
|
|
627
|
+
sample_agent_action: ModelAgentAction,
|
|
628
|
+
) -> None:
|
|
629
|
+
"""Missing correlation_id should be auto-generated."""
|
|
630
|
+
await writer.write_agent_actions([sample_agent_action])
|
|
631
|
+
|
|
632
|
+
# Writer should complete successfully
|
|
633
|
+
mock_conn.executemany.assert_called_once()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# =============================================================================
|
|
637
|
+
# All Write Methods Coverage
|
|
638
|
+
# =============================================================================
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class TestAllWriteMethods:
|
|
642
|
+
"""Ensure all write methods are callable and work correctly."""
|
|
643
|
+
|
|
644
|
+
@pytest.mark.asyncio
|
|
645
|
+
async def test_write_transformation_events(
|
|
646
|
+
self,
|
|
647
|
+
writer: WriterAgentActionsPostgres,
|
|
648
|
+
mock_conn: AsyncMock,
|
|
649
|
+
sample_transformation_event: ModelTransformationEvent,
|
|
650
|
+
) -> None:
|
|
651
|
+
"""Transformation events should write successfully."""
|
|
652
|
+
result = await writer.write_transformation_events([sample_transformation_event])
|
|
653
|
+
|
|
654
|
+
assert result == 1
|
|
655
|
+
mock_conn.executemany.assert_called_once()
|
|
656
|
+
|
|
657
|
+
call_args = mock_conn.executemany.call_args
|
|
658
|
+
sql = call_args[0][0]
|
|
659
|
+
assert "agent_transformation_events" in sql
|
|
660
|
+
assert "ON CONFLICT (id) DO NOTHING" in sql
|
|
661
|
+
|
|
662
|
+
@pytest.mark.asyncio
|
|
663
|
+
async def test_write_performance_metrics(
|
|
664
|
+
self,
|
|
665
|
+
writer: WriterAgentActionsPostgres,
|
|
666
|
+
mock_conn: AsyncMock,
|
|
667
|
+
sample_performance_metric: ModelPerformanceMetric,
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Performance metrics should write successfully."""
|
|
670
|
+
result = await writer.write_performance_metrics([sample_performance_metric])
|
|
671
|
+
|
|
672
|
+
assert result == 1
|
|
673
|
+
mock_conn.executemany.assert_called_once()
|
|
674
|
+
|
|
675
|
+
call_args = mock_conn.executemany.call_args
|
|
676
|
+
sql = call_args[0][0]
|
|
677
|
+
assert "router_performance_metrics" in sql
|
|
678
|
+
assert "ON CONFLICT (id) DO NOTHING" in sql
|
|
679
|
+
|
|
680
|
+
@pytest.mark.asyncio
|
|
681
|
+
async def test_write_detection_failures(
|
|
682
|
+
self,
|
|
683
|
+
writer: WriterAgentActionsPostgres,
|
|
684
|
+
mock_conn: AsyncMock,
|
|
685
|
+
sample_detection_failure: ModelDetectionFailure,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Detection failures should write successfully."""
|
|
688
|
+
result = await writer.write_detection_failures([sample_detection_failure])
|
|
689
|
+
|
|
690
|
+
assert result == 1
|
|
691
|
+
mock_conn.executemany.assert_called_once()
|
|
692
|
+
|
|
693
|
+
call_args = mock_conn.executemany.call_args
|
|
694
|
+
sql = call_args[0][0]
|
|
695
|
+
assert "agent_detection_failures" in sql
|
|
696
|
+
assert "ON CONFLICT (correlation_id) DO NOTHING" in sql
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
__all__ = [
|
|
700
|
+
"TestBatchWriteEmpty",
|
|
701
|
+
"TestBatchWriteSingleItem",
|
|
702
|
+
"TestBatchWriteMultipleItems",
|
|
703
|
+
"TestIdempotency",
|
|
704
|
+
"TestCircuitBreakerState",
|
|
705
|
+
"TestCircuitBreakerErrorHandling",
|
|
706
|
+
"TestJSONSerialization",
|
|
707
|
+
"TestCorrelationId",
|
|
708
|
+
"TestAllWriteMethods",
|
|
709
|
+
]
|