phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Tests for dead-letter queue support and replay tooling."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.shared.dead_letter import (
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
dlq_length,
|
||||
dlq_summary,
|
||||
peek_dlq,
|
||||
purge_dlq,
|
||||
replay_all,
|
||||
replay_one,
|
||||
send_to_dlq,
|
||||
wrap_dlq_entry,
|
||||
)
|
||||
from services.shared.redis_keys import dlq_key, queue_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal async Redis fake backed by plain dicts."""
|
||||
|
||||
def __init__(self):
|
||||
self._data: dict[str, list[str]] = {}
|
||||
|
||||
async def rpush(self, key: str, value: str) -> int:
|
||||
self._data.setdefault(key, []).append(value)
|
||||
return len(self._data[key])
|
||||
|
||||
async def lpop(self, key: str) -> str | None:
|
||||
lst = self._data.get(key, [])
|
||||
if not lst:
|
||||
return None
|
||||
return lst.pop(0)
|
||||
|
||||
async def llen(self, key: str) -> int:
|
||||
return len(self._data.get(key, []))
|
||||
|
||||
async def lrange(self, key: str, start: int, end: int) -> list[str]:
|
||||
lst = self._data.get(key, [])
|
||||
return lst[start:end + 1]
|
||||
|
||||
async def delete(self, key: str) -> int:
|
||||
if key in self._data:
|
||||
del self._data[key]
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rds():
|
||||
return FakeRedis()
|
||||
|
||||
|
||||
SAMPLE_JOB = {"ticker": "AAPL", "source_type": "news_api", "source_id": "src-1"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wrap_dlq_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_wrap_dlq_entry_structure():
|
||||
entry = wrap_dlq_entry(SAMPLE_JOB, "ingestion", "timeout", attempt=2, worker="ingestion_worker")
|
||||
assert entry["original_payload"] == SAMPLE_JOB
|
||||
assert entry["queue"] == "ingestion"
|
||||
assert entry["error"] == "timeout"
|
||||
assert entry["attempt"] == 2
|
||||
assert entry["worker"] == "ingestion_worker"
|
||||
assert "dead_lettered_at" in entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_to_dlq / dlq_length
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_dlq_and_length(rds):
|
||||
await send_to_dlq(rds, "parsing", SAMPLE_JOB, error="parse failure", attempt=3)
|
||||
length = await dlq_length(rds, "parsing")
|
||||
assert length == 1
|
||||
|
||||
# Verify the stored entry
|
||||
raw = rds._data[dlq_key("parsing")][0]
|
||||
entry = json.loads(raw)
|
||||
assert entry["original_payload"] == SAMPLE_JOB
|
||||
assert entry["error"] == "parse failure"
|
||||
assert entry["attempt"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# peek_dlq
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peek_dlq(rds):
|
||||
for i in range(5):
|
||||
await send_to_dlq(rds, "extraction", {"doc": i}, error=f"err-{i}")
|
||||
|
||||
items = await peek_dlq(rds, "extraction", start=0, count=3)
|
||||
assert len(items) == 3
|
||||
assert items[0]["original_payload"]["doc"] == 0
|
||||
assert items[2]["original_payload"]["doc"] == 2
|
||||
|
||||
# DLQ should still have all 5 items (peek doesn't remove)
|
||||
assert await dlq_length(rds, "extraction") == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# replay_one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_one(rds):
|
||||
await send_to_dlq(rds, "ingestion", SAMPLE_JOB, error="timeout")
|
||||
await send_to_dlq(rds, "ingestion", {"ticker": "MSFT"}, error="timeout")
|
||||
|
||||
entry = await replay_one(rds, "ingestion")
|
||||
assert entry is not None
|
||||
assert entry["original_payload"] == SAMPLE_JOB
|
||||
|
||||
# Original payload should now be in the source queue
|
||||
source_queue = queue_key("ingestion")
|
||||
raw = await rds.lpop(source_queue)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == SAMPLE_JOB
|
||||
|
||||
# DLQ should have 1 remaining
|
||||
assert await dlq_length(rds, "ingestion") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_one_empty(rds):
|
||||
result = await replay_one(rds, "ingestion")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# replay_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_all(rds):
|
||||
for i in range(4):
|
||||
await send_to_dlq(rds, "aggregation", {"idx": i}, error="fail")
|
||||
|
||||
count = await replay_all(rds, "aggregation")
|
||||
assert count == 4
|
||||
|
||||
# DLQ should be empty
|
||||
assert await dlq_length(rds, "aggregation") == 0
|
||||
|
||||
# Source queue should have 4 items
|
||||
source_queue = queue_key("aggregation")
|
||||
assert await rds.llen(source_queue) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_all_empty(rds):
|
||||
count = await replay_all(rds, "aggregation")
|
||||
assert count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# purge_dlq
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_dlq(rds):
|
||||
for i in range(3):
|
||||
await send_to_dlq(rds, "parsing", {"idx": i}, error="fail")
|
||||
|
||||
removed = await purge_dlq(rds, "parsing")
|
||||
assert removed == 3
|
||||
assert await dlq_length(rds, "parsing") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_dlq_empty(rds):
|
||||
removed = await purge_dlq(rds, "parsing")
|
||||
assert removed == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dlq_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dlq_summary(rds):
|
||||
await send_to_dlq(rds, "ingestion", {"a": 1}, error="e")
|
||||
await send_to_dlq(rds, "ingestion", {"b": 2}, error="e")
|
||||
await send_to_dlq(rds, "parsing", {"c": 3}, error="e")
|
||||
|
||||
summary = await dlq_summary(rds, ["ingestion", "parsing", "extraction"])
|
||||
assert summary == {"ingestion": 2, "parsing": 1, "extraction": 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEFAULT_MAX_ATTEMPTS constant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_default_max_attempts():
|
||||
assert DEFAULT_MAX_ATTEMPTS == 3
|
||||
Reference in New Issue
Block a user