Initial project structure
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
183
tests/conftest.py
Normal file
183
tests/conftest.py
Normal 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
321
tests/test_client.py
Normal 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
137
tests/test_event_builder.py
Normal 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"
|
||||
147
tests/test_prediction_cache.py
Normal file
147
tests/test_prediction_cache.py
Normal 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
133
tests/test_queue.py
Normal 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
207
tests/test_submitter.py
Normal 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
|
||||
Reference in New Issue
Block a user