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.
Files changed (88) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +4 -0
  3. omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
  4. omnibase_infra/errors/__init__.py +18 -0
  5. omnibase_infra/errors/repository/__init__.py +78 -0
  6. omnibase_infra/errors/repository/errors_repository.py +424 -0
  7. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  8. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  9. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  10. omnibase_infra/models/__init__.py +9 -0
  11. omnibase_infra/models/event_bus/__init__.py +22 -0
  12. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  13. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  14. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  15. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  16. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  17. omnibase_infra/models/validation/__init__.py +8 -0
  18. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  19. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  20. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  21. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  22. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  23. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  24. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  25. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  26. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  27. omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
  28. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  29. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  30. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  31. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  32. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  33. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  34. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  35. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  36. omnibase_infra/runtime/__init__.py +11 -0
  37. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  38. omnibase_infra/runtime/db/__init__.py +73 -0
  39. omnibase_infra/runtime/db/models/__init__.py +41 -0
  40. omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
  41. omnibase_infra/runtime/db/postgres_repository_runtime.py +545 -0
  42. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  43. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  44. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  45. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  46. omnibase_infra/services/__init__.py +36 -0
  47. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  48. omnibase_infra/services/contract_publisher/config.py +199 -0
  49. omnibase_infra/services/contract_publisher/errors.py +243 -0
  50. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  51. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  52. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  53. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  54. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  55. omnibase_infra/services/contract_publisher/service.py +617 -0
  56. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  57. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  58. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  59. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  60. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  61. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  62. omnibase_infra/services/observability/__init__.py +40 -0
  63. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  64. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  65. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  66. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  67. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  68. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  69. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  70. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  71. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  72. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  73. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  74. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  75. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  76. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  77. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  78. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  79. omnibase_infra/validation/__init__.py +12 -0
  80. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  81. omnibase_infra/validation/infra_validators.py +4 -1
  82. omnibase_infra/validation/validation_exemptions.yaml +111 -0
  83. omnibase_infra/validation/validator_declarative_node.py +850 -0
  84. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/METADATA +2 -2
  85. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/RECORD +88 -30
  86. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/WHEEL +0 -0
  87. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/entry_points.txt +0 -0
  88. {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
+ ]