Initial project structure

This commit is contained in:
2026-02-07 22:36:37 -06:00
commit 0b29302f43
26 changed files with 3308 additions and 0 deletions

0
tests/__init__.py Normal file
View File

183
tests/conftest.py Normal file
View File

@@ -0,0 +1,183 @@
"""Shared test fixtures."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID, uuid4
import pytest
from mosaicstack_telemetry.config import TelemetryConfig
from mosaicstack_telemetry.types.events import (
Complexity,
Harness,
Outcome,
Provider,
TaskCompletionEvent,
TaskType,
)
from mosaicstack_telemetry.types.predictions import (
CorrectionFactors,
PredictionData,
PredictionMetadata,
PredictionQuery,
PredictionResponse,
QualityPrediction,
TokenDistribution,
)
TEST_API_KEY = "a" * 64
TEST_INSTANCE_ID = "12345678-1234-1234-1234-123456789abc"
TEST_SERVER_URL = "https://telemetry.example.com"
@pytest.fixture()
def config() -> TelemetryConfig:
"""Create a valid test configuration."""
return TelemetryConfig(
server_url=TEST_SERVER_URL,
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
submit_interval_seconds=1.0,
max_queue_size=100,
batch_size=10,
request_timeout_seconds=5.0,
max_retries=1,
)
@pytest.fixture()
def dry_run_config() -> TelemetryConfig:
"""Create a test configuration with dry_run enabled."""
return TelemetryConfig(
server_url=TEST_SERVER_URL,
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
submit_interval_seconds=1.0,
max_queue_size=100,
batch_size=10,
request_timeout_seconds=5.0,
dry_run=True,
max_retries=1,
)
@pytest.fixture()
def disabled_config() -> TelemetryConfig:
"""Create a disabled test configuration."""
return TelemetryConfig(
server_url=TEST_SERVER_URL,
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
enabled=False,
)
@pytest.fixture()
def sample_instance_id() -> UUID:
"""Return a fixed instance UUID for testing."""
return UUID(TEST_INSTANCE_ID)
@pytest.fixture()
def sample_event(sample_instance_id: UUID) -> TaskCompletionEvent:
"""Create a sample task completion event."""
return TaskCompletionEvent(
instance_id=sample_instance_id,
event_id=uuid4(),
timestamp=datetime.now(timezone.utc),
task_duration_ms=30000,
task_type=TaskType.IMPLEMENTATION,
complexity=Complexity.MEDIUM,
harness=Harness.CLAUDE_CODE,
model="claude-sonnet-4-20250514",
provider=Provider.ANTHROPIC,
estimated_input_tokens=5000,
estimated_output_tokens=2000,
actual_input_tokens=5200,
actual_output_tokens=1800,
estimated_cost_usd_micros=10000,
actual_cost_usd_micros=9500,
quality_gate_passed=True,
quality_gates_run=[],
quality_gates_failed=[],
context_compactions=0,
context_rotations=0,
context_utilization_final=0.4,
outcome=Outcome.SUCCESS,
retry_count=0,
language="python",
repo_size_category=None,
)
def make_event(instance_id: UUID | None = None) -> TaskCompletionEvent:
"""Factory helper to create a sample event with optional overrides."""
return TaskCompletionEvent(
instance_id=instance_id or UUID(TEST_INSTANCE_ID),
event_id=uuid4(),
timestamp=datetime.now(timezone.utc),
task_duration_ms=15000,
task_type=TaskType.DEBUGGING,
complexity=Complexity.LOW,
harness=Harness.AIDER,
model="gpt-4o",
provider=Provider.OPENAI,
estimated_input_tokens=1000,
estimated_output_tokens=500,
actual_input_tokens=1100,
actual_output_tokens=480,
estimated_cost_usd_micros=3000,
actual_cost_usd_micros=2800,
quality_gate_passed=True,
quality_gates_run=[],
quality_gates_failed=[],
context_compactions=0,
context_rotations=0,
context_utilization_final=0.2,
outcome=Outcome.SUCCESS,
retry_count=0,
)
@pytest.fixture()
def sample_prediction_query() -> PredictionQuery:
"""Create a sample prediction query."""
return PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="claude-sonnet-4-20250514",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
@pytest.fixture()
def sample_prediction_response() -> PredictionResponse:
"""Create a sample prediction response."""
return PredictionResponse(
prediction=PredictionData(
input_tokens=TokenDistribution(p10=1000, p25=2000, median=3000, p75=4000, p90=5000),
output_tokens=TokenDistribution(p10=500, p25=1000, median=1500, p75=2000, p90=2500),
cost_usd_micros={"p10": 1000, "median": 3000, "p90": 5000},
duration_ms={"p10": 10000, "median": 30000, "p90": 60000},
correction_factors=CorrectionFactors(input=1.05, output=0.95),
quality=QualityPrediction(gate_pass_rate=0.85, success_rate=0.9),
),
metadata=PredictionMetadata(
sample_size=150,
fallback_level=0,
confidence="high",
),
)
def make_batch_response_json(events: list[TaskCompletionEvent]) -> dict:
"""Create a batch response JSON dict for a list of events (all accepted)."""
return {
"accepted": len(events),
"rejected": 0,
"results": [
{"event_id": str(e.event_id), "status": "accepted", "error": None}
for e in events
],
}

321
tests/test_client.py Normal file
View File

@@ -0,0 +1,321 @@
"""Tests for TelemetryClient."""
from __future__ import annotations
import httpx
import pytest
import respx
from mosaicstack_telemetry.client import TelemetryClient
from mosaicstack_telemetry.config import TelemetryConfig
from mosaicstack_telemetry.types.events import (
Complexity,
Outcome,
Provider,
TaskCompletionEvent,
TaskType,
)
from mosaicstack_telemetry.types.predictions import (
PredictionMetadata,
PredictionQuery,
PredictionResponse,
)
from tests.conftest import (
TEST_API_KEY,
TEST_INSTANCE_ID,
TEST_SERVER_URL,
make_batch_response_json,
make_event,
)
class TestTelemetryClientLifecycle:
"""Tests for client start/stop lifecycle."""
def test_start_stop_sync(self, config: TelemetryConfig) -> None:
"""Client can start and stop synchronously."""
client = TelemetryClient(config)
client.start()
assert client.is_running is True
client.stop()
assert client.is_running is False
async def test_start_stop_async(self, config: TelemetryConfig) -> None:
"""Client can start and stop asynchronously."""
client = TelemetryClient(config)
await client.start_async()
assert client.is_running is True
await client.stop_async()
assert client.is_running is False
def test_start_disabled(self, disabled_config: TelemetryConfig) -> None:
"""Starting a disabled client is a no-op."""
client = TelemetryClient(disabled_config)
client.start()
assert client.is_running is False
client.stop()
async def test_start_async_disabled(self, disabled_config: TelemetryConfig) -> None:
"""Starting a disabled async client is a no-op."""
client = TelemetryClient(disabled_config)
await client.start_async()
assert client.is_running is False
await client.stop_async()
class TestTelemetryClientTrack:
"""Tests for the track() method."""
def test_track_queues_event(self, config: TelemetryConfig) -> None:
"""track() adds event to the queue."""
client = TelemetryClient(config)
event = make_event()
client.track(event)
assert client.queue_size == 1
def test_track_multiple_events(self, config: TelemetryConfig) -> None:
"""Multiple events can be tracked."""
client = TelemetryClient(config)
for _ in range(5):
client.track(make_event())
assert client.queue_size == 5
def test_track_disabled_drops_event(self, disabled_config: TelemetryConfig) -> None:
"""track() silently drops events when disabled."""
client = TelemetryClient(disabled_config)
client.track(make_event())
assert client.queue_size == 0
def test_track_never_throws(self, config: TelemetryConfig) -> None:
"""track() should never raise exceptions."""
client = TelemetryClient(config)
# This should not raise even with invalid-ish usage
event = make_event()
client.track(event)
assert client.queue_size == 1
class TestTelemetryClientContextManager:
"""Tests for context manager support."""
@respx.mock
def test_sync_context_manager(self, config: TelemetryConfig) -> None:
"""Sync context manager starts and stops correctly."""
# Mock any potential flush calls
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(
202,
json={"accepted": 0, "rejected": 0, "results": []},
)
)
with TelemetryClient(config) as client:
assert client.is_running is True
client.track(make_event())
assert client.is_running is False
@respx.mock
async def test_async_context_manager(self, config: TelemetryConfig) -> None:
"""Async context manager starts and stops correctly."""
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(
202,
json={"accepted": 0, "rejected": 0, "results": []},
)
)
async with TelemetryClient(config) as client:
assert client.is_running is True
client.track(make_event())
assert client.is_running is False
class TestTelemetryClientPredictions:
"""Tests for prediction caching and retrieval."""
def test_get_prediction_miss(self, config: TelemetryConfig) -> None:
"""get_prediction returns None on cache miss."""
client = TelemetryClient(config)
query = PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="test-model",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
assert client.get_prediction(query) is None
def test_get_prediction_after_cache_populated(
self, config: TelemetryConfig
) -> None:
"""get_prediction returns cached value."""
client = TelemetryClient(config)
query = PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="test-model",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
response = PredictionResponse(
prediction=None,
metadata=PredictionMetadata(
sample_size=50,
fallback_level=0,
confidence="medium",
),
)
# Directly populate the cache
client._prediction_cache.put(query, response)
result = client.get_prediction(query)
assert result is not None
assert result.metadata.sample_size == 50
@respx.mock
async def test_refresh_predictions_async(self, config: TelemetryConfig) -> None:
"""refresh_predictions fetches and caches predictions."""
query = PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="test-model",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
response_data = {
"results": [
{
"prediction": None,
"metadata": {
"sample_size": 75,
"fallback_level": 1,
"confidence": "medium",
},
}
]
}
respx.post(f"{TEST_SERVER_URL}/v1/predictions/batch").mock(
return_value=httpx.Response(200, json=response_data)
)
client = TelemetryClient(config)
await client.refresh_predictions([query])
result = client.get_prediction(query)
assert result is not None
assert result.metadata.sample_size == 75
@respx.mock
def test_refresh_predictions_sync(self, config: TelemetryConfig) -> None:
"""refresh_predictions_sync fetches and caches predictions."""
query = PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="test-model",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
response_data = {
"results": [
{
"prediction": None,
"metadata": {
"sample_size": 60,
"fallback_level": 0,
"confidence": "low",
},
}
]
}
respx.post(f"{TEST_SERVER_URL}/v1/predictions/batch").mock(
return_value=httpx.Response(200, json=response_data)
)
client = TelemetryClient(config)
client.refresh_predictions_sync([query])
result = client.get_prediction(query)
assert result is not None
assert result.metadata.sample_size == 60
@respx.mock
async def test_refresh_predictions_server_error(
self, config: TelemetryConfig
) -> None:
"""refresh_predictions handles server errors gracefully."""
query = PredictionQuery(
task_type=TaskType.IMPLEMENTATION,
model="test-model",
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
respx.post(f"{TEST_SERVER_URL}/v1/predictions/batch").mock(
return_value=httpx.Response(500, text="Internal Server Error")
)
client = TelemetryClient(config)
# Should not raise
await client.refresh_predictions([query])
# Cache should still be empty
assert client.get_prediction(query) is None
async def test_refresh_predictions_empty_list(
self, config: TelemetryConfig
) -> None:
"""refresh_predictions with empty list is a no-op."""
client = TelemetryClient(config)
await client.refresh_predictions([])
class TestTelemetryClientConfig:
"""Tests for configuration handling."""
def test_config_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Environment variables override defaults."""
monkeypatch.setenv("MOSAIC_TELEMETRY_ENABLED", "false")
monkeypatch.setenv("MOSAIC_TELEMETRY_SERVER_URL", "https://env-server.com")
monkeypatch.setenv("MOSAIC_TELEMETRY_API_KEY", "b" * 64)
monkeypatch.setenv("MOSAIC_TELEMETRY_INSTANCE_ID", TEST_INSTANCE_ID)
config = TelemetryConfig()
assert config.enabled is False
assert config.server_url == "https://env-server.com"
assert config.api_key == "b" * 64
assert config.instance_id == TEST_INSTANCE_ID
def test_config_validation_errors(self) -> None:
"""Invalid config produces validation errors."""
config = TelemetryConfig(
server_url="",
api_key="short",
instance_id="not-a-uuid",
)
errors = config.validate()
assert len(errors) >= 3
def test_config_validation_success(self, config: TelemetryConfig) -> None:
"""Valid config produces no validation errors."""
errors = config.validate()
assert errors == []
def test_config_strips_trailing_slash(self) -> None:
"""server_url trailing slashes are stripped."""
config = TelemetryConfig(
server_url="https://example.com/",
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
)
assert config.server_url == "https://example.com"
def test_explicit_values_override_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Explicit constructor values take priority over env vars."""
monkeypatch.setenv("MOSAIC_TELEMETRY_SERVER_URL", "https://env-server.com")
config = TelemetryConfig(
server_url="https://explicit-server.com",
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
)
assert config.server_url == "https://explicit-server.com"

137
tests/test_event_builder.py Normal file
View File

@@ -0,0 +1,137 @@
"""Tests for EventBuilder."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from mosaicstack_telemetry.event_builder import EventBuilder
from mosaicstack_telemetry.types.events import (
Complexity,
Harness,
Outcome,
Provider,
QualityGate,
RepoSizeCategory,
TaskType,
)
TEST_INSTANCE_ID = "12345678-1234-1234-1234-123456789abc"
class TestEventBuilder:
"""Tests for the fluent event builder."""
def test_build_complete_event(self) -> None:
"""Build an event with all fields set."""
event = (
EventBuilder(instance_id=TEST_INSTANCE_ID)
.task_type(TaskType.IMPLEMENTATION)
.model("claude-sonnet-4-20250514")
.provider(Provider.ANTHROPIC)
.harness_type(Harness.CLAUDE_CODE)
.complexity_level(Complexity.HIGH)
.outcome_value(Outcome.SUCCESS)
.duration_ms(45000)
.tokens(estimated_in=5000, estimated_out=2000, actual_in=5200, actual_out=1800)
.cost(estimated=50000, actual=48000)
.quality(
passed=True,
gates_run=[QualityGate.LINT, QualityGate.TEST],
gates_failed=[],
)
.context(compactions=1, rotations=0, utilization=0.4)
.retry_count(0)
.language("python")
.repo_size(RepoSizeCategory.MEDIUM)
.build()
)
assert event.instance_id == UUID(TEST_INSTANCE_ID)
assert event.task_type == TaskType.IMPLEMENTATION
assert event.model == "claude-sonnet-4-20250514"
assert event.provider == Provider.ANTHROPIC
assert event.harness == Harness.CLAUDE_CODE
assert event.complexity == Complexity.HIGH
assert event.outcome == Outcome.SUCCESS
assert event.task_duration_ms == 45000
assert event.estimated_input_tokens == 5000
assert event.estimated_output_tokens == 2000
assert event.actual_input_tokens == 5200
assert event.actual_output_tokens == 1800
assert event.estimated_cost_usd_micros == 50000
assert event.actual_cost_usd_micros == 48000
assert event.quality_gate_passed is True
assert event.quality_gates_run == [QualityGate.LINT, QualityGate.TEST]
assert event.quality_gates_failed == []
assert event.context_compactions == 1
assert event.context_rotations == 0
assert event.context_utilization_final == 0.4
assert event.retry_count == 0
assert event.language == "python"
assert event.repo_size_category == RepoSizeCategory.MEDIUM
def test_auto_generated_defaults(self) -> None:
"""event_id and timestamp are auto-generated."""
event = (
EventBuilder(instance_id=TEST_INSTANCE_ID)
.task_type(TaskType.DEBUGGING)
.model("gpt-4o")
.provider(Provider.OPENAI)
.build()
)
assert event.event_id is not None
assert event.timestamp is not None
assert event.timestamp.tzinfo is not None
def test_custom_event_id(self) -> None:
"""Custom event_id can be set."""
custom_id = "abcdef12-1234-1234-1234-123456789abc"
event = (
EventBuilder(instance_id=TEST_INSTANCE_ID)
.event_id(custom_id)
.model("test-model")
.build()
)
assert event.event_id == UUID(custom_id)
def test_custom_timestamp(self) -> None:
"""Custom timestamp can be set."""
ts = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
event = (
EventBuilder(instance_id=TEST_INSTANCE_ID)
.timestamp(ts)
.model("test-model")
.build()
)
assert event.timestamp == ts
def test_minimal_event_defaults(self) -> None:
"""Minimal event has sensible defaults."""
event = EventBuilder(instance_id=TEST_INSTANCE_ID).model("test-model").build()
assert event.task_type == TaskType.UNKNOWN
assert event.complexity == Complexity.MEDIUM
assert event.harness == Harness.UNKNOWN
assert event.provider == Provider.UNKNOWN
assert event.outcome == Outcome.FAILURE
assert event.task_duration_ms == 0
assert event.retry_count == 0
assert event.language is None
assert event.repo_size_category is None
def test_quality_defaults_to_empty_lists(self) -> None:
"""Quality gate lists default to empty."""
event = EventBuilder(instance_id=TEST_INSTANCE_ID).model("m").build()
assert event.quality_gates_run == []
assert event.quality_gates_failed == []
assert event.quality_gate_passed is False
def test_schema_version(self) -> None:
"""Schema version defaults to 1.0."""
event = EventBuilder(instance_id=TEST_INSTANCE_ID).model("m").build()
assert event.schema_version == "1.0"

View File

@@ -0,0 +1,147 @@
"""Tests for PredictionCache."""
from __future__ import annotations
import threading
import time
from mosaicstack_telemetry.prediction_cache import PredictionCache
from mosaicstack_telemetry.types.events import Complexity, Provider, TaskType
from mosaicstack_telemetry.types.predictions import (
PredictionMetadata,
PredictionQuery,
PredictionResponse,
)
def _make_query(
task_type: TaskType = TaskType.IMPLEMENTATION,
model: str = "claude-sonnet-4-20250514",
) -> PredictionQuery:
return PredictionQuery(
task_type=task_type,
model=model,
provider=Provider.ANTHROPIC,
complexity=Complexity.MEDIUM,
)
def _make_response(sample_size: int = 100) -> PredictionResponse:
return PredictionResponse(
prediction=None,
metadata=PredictionMetadata(
sample_size=sample_size,
fallback_level=0,
confidence="high",
),
)
class TestPredictionCache:
"""Tests for the TTL-based prediction cache."""
def test_cache_hit(self) -> None:
"""Cached predictions are returned on hit."""
cache = PredictionCache(ttl_seconds=60.0)
query = _make_query()
response = _make_response()
cache.put(query, response)
result = cache.get(query)
assert result is not None
assert result.metadata.sample_size == 100
def test_cache_miss(self) -> None:
"""Missing keys return None."""
cache = PredictionCache(ttl_seconds=60.0)
query = _make_query()
result = cache.get(query)
assert result is None
def test_cache_expiry(self) -> None:
"""Expired entries return None."""
cache = PredictionCache(ttl_seconds=0.05)
query = _make_query()
response = _make_response()
cache.put(query, response)
time.sleep(0.1)
result = cache.get(query)
assert result is None
def test_different_queries_different_keys(self) -> None:
"""Different queries map to different cache entries."""
cache = PredictionCache(ttl_seconds=60.0)
query1 = _make_query(task_type=TaskType.IMPLEMENTATION)
query2 = _make_query(task_type=TaskType.DEBUGGING)
cache.put(query1, _make_response(sample_size=100))
cache.put(query2, _make_response(sample_size=200))
result1 = cache.get(query1)
result2 = cache.get(query2)
assert result1 is not None
assert result2 is not None
assert result1.metadata.sample_size == 100
assert result2.metadata.sample_size == 200
def test_cache_clear(self) -> None:
"""Clear removes all entries."""
cache = PredictionCache(ttl_seconds=60.0)
query = _make_query()
cache.put(query, _make_response())
assert cache.size == 1
cache.clear()
assert cache.size == 0
assert cache.get(query) is None
def test_cache_overwrite(self) -> None:
"""Putting a new value for the same key overwrites."""
cache = PredictionCache(ttl_seconds=60.0)
query = _make_query()
cache.put(query, _make_response(sample_size=100))
cache.put(query, _make_response(sample_size=200))
result = cache.get(query)
assert result is not None
assert result.metadata.sample_size == 200
def test_thread_safety(self) -> None:
"""Cache handles concurrent access from multiple threads."""
cache = PredictionCache(ttl_seconds=60.0)
errors: list[Exception] = []
iterations = 100
def writer(thread_id: int) -> None:
try:
for i in range(iterations):
query = _make_query(model=f"model-{thread_id}-{i}")
cache.put(query, _make_response(sample_size=i))
except Exception as e:
errors.append(e)
def reader(thread_id: int) -> None:
try:
for i in range(iterations):
query = _make_query(model=f"model-{thread_id}-{i}")
cache.get(query) # May or may not hit
except Exception as e:
errors.append(e)
threads: list[threading.Thread] = []
for tid in range(4):
threads.append(threading.Thread(target=writer, args=(tid,)))
threads.append(threading.Thread(target=reader, args=(tid,)))
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert not errors, f"Thread errors: {errors}"

133
tests/test_queue.py Normal file
View File

@@ -0,0 +1,133 @@
"""Tests for EventQueue."""
from __future__ import annotations
import threading
from mosaicstack_telemetry.queue import EventQueue
from tests.conftest import make_event
class TestEventQueue:
"""Tests for the bounded thread-safe event queue."""
def test_put_and_drain(self) -> None:
"""Events can be put in and drained out in FIFO order."""
queue = EventQueue(max_size=10)
e1 = make_event()
e2 = make_event()
queue.put(e1)
queue.put(e2)
drained = queue.drain(10)
assert len(drained) == 2
assert drained[0].event_id == e1.event_id
assert drained[1].event_id == e2.event_id
def test_drain_max_items(self) -> None:
"""Drain respects the max_items limit."""
queue = EventQueue(max_size=10)
for _ in range(5):
queue.put(make_event())
drained = queue.drain(3)
assert len(drained) == 3
assert queue.size == 2
def test_drain_empty_queue(self) -> None:
"""Draining an empty queue returns empty list."""
queue = EventQueue(max_size=10)
drained = queue.drain(5)
assert drained == []
def test_bounded_fifo_eviction(self) -> None:
"""When queue is full, oldest events are evicted."""
queue = EventQueue(max_size=3)
events = [make_event() for _ in range(5)]
for e in events:
queue.put(e)
assert queue.size == 3
drained = queue.drain(3)
# Should have the last 3 events (oldest 2 were evicted)
assert drained[0].event_id == events[2].event_id
assert drained[1].event_id == events[3].event_id
assert drained[2].event_id == events[4].event_id
def test_size_property(self) -> None:
"""Size property reflects current queue length."""
queue = EventQueue(max_size=10)
assert queue.size == 0
queue.put(make_event())
assert queue.size == 1
queue.put(make_event())
assert queue.size == 2
queue.drain(1)
assert queue.size == 1
def test_is_empty_property(self) -> None:
"""is_empty property works correctly."""
queue = EventQueue(max_size=10)
assert queue.is_empty is True
queue.put(make_event())
assert queue.is_empty is False
queue.drain(1)
assert queue.is_empty is True
def test_put_back(self) -> None:
"""put_back re-adds events to the front of the queue."""
queue = EventQueue(max_size=10)
e1 = make_event()
e2 = make_event()
queue.put(e1)
queue.put_back([e2])
drained = queue.drain(2)
# e2 should be first (put_back adds to front)
assert drained[0].event_id == e2.event_id
assert drained[1].event_id == e1.event_id
def test_put_back_respects_max_size(self) -> None:
"""put_back doesn't exceed max_size."""
queue = EventQueue(max_size=3)
for _ in range(3):
queue.put(make_event())
events_to_add = [make_event() for _ in range(5)]
queue.put_back(events_to_add)
assert queue.size == 3
def test_thread_safety_concurrent_put_drain(self) -> None:
"""Queue handles concurrent put and drain operations."""
queue = EventQueue(max_size=1000)
total_puts = 500
errors: list[Exception] = []
def put_events() -> None:
try:
for _ in range(total_puts):
queue.put(make_event())
except Exception as e:
errors.append(e)
def drain_events() -> None:
try:
drained_count = 0
while drained_count < total_puts:
batch = queue.drain(10)
drained_count += len(batch)
if not batch:
threading.Event().wait(0.001)
except Exception as e:
errors.append(e)
put_thread = threading.Thread(target=put_events)
drain_thread = threading.Thread(target=drain_events)
put_thread.start()
drain_thread.start()
put_thread.join(timeout=5)
drain_thread.join(timeout=5)
assert not errors, f"Thread errors: {errors}"

207
tests/test_submitter.py Normal file
View File

@@ -0,0 +1,207 @@
"""Tests for batch submission logic."""
from __future__ import annotations
import httpx
import pytest
import respx
from mosaicstack_telemetry.config import TelemetryConfig
from mosaicstack_telemetry.submitter import submit_batch_async, submit_batch_sync
from tests.conftest import (
TEST_API_KEY,
TEST_INSTANCE_ID,
TEST_SERVER_URL,
make_batch_response_json,
make_event,
)
@pytest.fixture()
def fast_config() -> TelemetryConfig:
"""Config with minimal retries and timeouts for fast tests."""
return TelemetryConfig(
server_url=TEST_SERVER_URL,
api_key=TEST_API_KEY,
instance_id=TEST_INSTANCE_ID,
max_retries=1,
request_timeout_seconds=2.0,
)
class TestSubmitBatchSync:
"""Tests for synchronous batch submission."""
@respx.mock
def test_successful_submission(self, fast_config: TelemetryConfig) -> None:
"""Successful 202 response returns BatchEventResponse."""
events = [make_event() for _ in range(3)]
response_json = make_batch_response_json(events)
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(202, json=response_json)
)
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is not None
assert result.accepted == 3
assert result.rejected == 0
@respx.mock
def test_429_with_retry_after(self, fast_config: TelemetryConfig) -> None:
"""429 response respects Retry-After header and retries."""
events = [make_event()]
response_json = make_batch_response_json(events)
route = respx.post(f"{TEST_SERVER_URL}/v1/events/batch")
route.side_effect = [
httpx.Response(429, headers={"Retry-After": "0.1"}),
httpx.Response(202, json=response_json),
]
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is not None
assert result.accepted == 1
@respx.mock
def test_403_returns_none(self, fast_config: TelemetryConfig) -> None:
"""403 response returns None immediately."""
events = [make_event()]
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(403, json={"error": "Forbidden"})
)
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is None
@respx.mock
def test_network_error_retries(self, fast_config: TelemetryConfig) -> None:
"""Network errors trigger retry with backoff."""
events = [make_event()]
response_json = make_batch_response_json(events)
route = respx.post(f"{TEST_SERVER_URL}/v1/events/batch")
route.side_effect = [
httpx.ConnectError("Connection refused"),
httpx.Response(202, json=response_json),
]
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is not None
assert result.accepted == 1
@respx.mock
def test_all_retries_exhausted(self, fast_config: TelemetryConfig) -> None:
"""When all retries fail, returns None."""
events = [make_event()]
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
side_effect=httpx.ConnectError("Connection refused")
)
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is None
def test_dry_run_mode(self, fast_config: TelemetryConfig) -> None:
"""Dry run mode logs but doesn't send."""
fast_config.dry_run = True
events = [make_event() for _ in range(5)]
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is not None
assert result.accepted == 5
assert result.rejected == 0
@respx.mock
def test_500_error_retries(self, fast_config: TelemetryConfig) -> None:
"""Server errors (500) trigger retries."""
events = [make_event()]
response_json = make_batch_response_json(events)
route = respx.post(f"{TEST_SERVER_URL}/v1/events/batch")
route.side_effect = [
httpx.Response(500, text="Internal Server Error"),
httpx.Response(202, json=response_json),
]
with httpx.Client() as client:
result = submit_batch_sync(client, fast_config, events)
assert result is not None
assert result.accepted == 1
class TestSubmitBatchAsync:
"""Tests for asynchronous batch submission."""
@respx.mock
async def test_successful_submission(self, fast_config: TelemetryConfig) -> None:
"""Successful 202 response returns BatchEventResponse."""
events = [make_event() for _ in range(3)]
response_json = make_batch_response_json(events)
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(202, json=response_json)
)
async with httpx.AsyncClient() as client:
result = await submit_batch_async(client, fast_config, events)
assert result is not None
assert result.accepted == 3
@respx.mock
async def test_429_with_retry_after(self, fast_config: TelemetryConfig) -> None:
"""429 response respects Retry-After and retries asynchronously."""
events = [make_event()]
response_json = make_batch_response_json(events)
route = respx.post(f"{TEST_SERVER_URL}/v1/events/batch")
route.side_effect = [
httpx.Response(429, headers={"Retry-After": "0.1"}),
httpx.Response(202, json=response_json),
]
async with httpx.AsyncClient() as client:
result = await submit_batch_async(client, fast_config, events)
assert result is not None
assert result.accepted == 1
@respx.mock
async def test_403_returns_none(self, fast_config: TelemetryConfig) -> None:
"""403 returns None immediately."""
events = [make_event()]
respx.post(f"{TEST_SERVER_URL}/v1/events/batch").mock(
return_value=httpx.Response(403, json={"error": "Forbidden"})
)
async with httpx.AsyncClient() as client:
result = await submit_batch_async(client, fast_config, events)
assert result is None
async def test_dry_run_mode(self, fast_config: TelemetryConfig) -> None:
"""Dry run mode returns mock response without HTTP."""
fast_config.dry_run = True
events = [make_event() for _ in range(3)]
async with httpx.AsyncClient() as client:
result = await submit_batch_async(client, fast_config, events)
assert result is not None
assert result.accepted == 3