""" Comprehensive tests for Pydantic schemas (v2). """ import pytest from datetime import datetime from pydantic import ValidationError from sentiment_engine.schemas.payload import ( NormalizedPayload, SourceType, AssetMention, EngagementMetrics ) from sentiment_engine.schemas.processed import ( ProcessedItem, EntityExtraction, SentimentScores, EmotionScores, EventClassification, EventType, TemporalAnchor, CredibilityScore ) from sentiment_engine.schemas.output import ( AssetSentiment, MarketSentiment, IndustrySentiment, SentimentOutput, PumpDumpScore, VelocityMetrics, EventFlag, ) from sentiment_engine.schemas.config import ( RSSConnectorConfig, APIConnectorConfig, TwitterConnectorConfig, RedditConnectorConfig, DiscordConnectorConfig, TelegramConnectorConfig, WebCrawlConnectorConfig, ConnectorConfig ) class TestSourceType: """Tests for SourceType enum""" def test_all_values(self): """All expected values should exist""" expected = {"news", "social", "exchange_ann", "regulatory", "corporate", "forum", "on_chain"} actual = {s.value for s in SourceType} assert actual == expected def test_string_conversion(self): """Should convert to string correctly""" assert str(SourceType.NEWS) == "news" assert str(SourceType.SOCIAL) == "social" class TestAssetMention: """Tests for AssetMention schema""" def test_valid_creation(self): """Should create valid AssetMention""" mention = AssetMention( asset_id="BTC", mention_span=(0, 3), confidence=0.9, source_text="BTC", mention_type="ticker" ) assert mention.asset_id == "BTC" assert mention.confidence == 0.9 def test_confidence_bounds(self): """Confidence should be in [0, 1]""" # Valid mention = AssetMention( asset_id="BTC", mention_span=(0, 3), confidence=0.5, source_text="BTC", mention_type="ticker" ) assert mention.confidence == 0.5 # Invalid - too high with pytest.raises(ValidationError): AssetMention( asset_id="BTC", mention_span=(0, 3), confidence=1.5, source_text="BTC", mention_type="ticker" ) # Invalid - too low with pytest.raises(ValidationError): AssetMention( asset_id="BTC", mention_span=(0, 3), confidence=-0.1, source_text="BTC", mention_type="ticker" ) def test_mention_span_tuple(self): """Mention span should be tuple of two ints""" mention = AssetMention( asset_id="BTC", mention_span=(10, 13), confidence=0.9, source_text="BTC", mention_type="ticker" ) assert mention.mention_span == (10, 13) assert len(mention.mention_span) == 2 class TestEngagementMetrics: """Tests for EngagementMetrics schema""" def test_defaults(self): """All fields should default to 0""" metrics = EngagementMetrics() assert metrics.retweets == 0 assert metrics.likes == 0 assert metrics.replies == 0 assert metrics.upvotes == 0 assert metrics.comments == 0 assert metrics.views == 0 assert metrics.shares == 0 def test_total_engagement(self): """total_engagement should sum all fields""" metrics = EngagementMetrics( retweets=10, likes=100, replies=5, upvotes=20, comments=15, views=1000, shares=3 ) assert metrics.total_engagement() == 1148 class TestNormalizedPayload: """Tests for NormalizedPayload schema""" def test_valid_creation(self): """Should create valid payload""" payload = NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.8, ingest_ts=1700000000.0, publish_ts=1700000000.0, content_length=100, raw_text="Test content", metadata={} ) assert payload.source_id == "test" assert payload.source_credibility_base == 0.8 def test_credibility_bounds(self): """Credibility should be in [0, 1]""" with pytest.raises(ValidationError): NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=1.5, ingest_ts=1700000000.0, content_length=10, raw_text="test", metadata={} ) def test_raw_text_validation(self): """raw_text should not be empty""" with pytest.raises(ValidationError): NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.8, ingest_ts=1700000000.0, content_length=0, raw_text="", metadata={} ) def test_content_length_matches(self): """content_length should match raw_text""" # This is a logical constraint, not enforced by schema payload = NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.8, ingest_ts=1700000000.0, content_length=100, raw_text="short", metadata={} ) assert payload.content_length != len(payload.raw_text) def test_age_minutes_property(self): """age_minutes should calculate correctly""" ingest_ts = 1700000000.0 publish_ts = 1700000000.0 - 3600 # 1 hour before payload = NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.8, ingest_ts=ingest_ts, publish_ts=publish_ts, content_length=10, raw_text="test", metadata={} ) assert payload.age_minutes == 60.0 def test_has_assets_property(self): """has_assets should reflect asset_mentions""" payload = NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.8, ingest_ts=1700000000.0, content_length=10, raw_text="test", metadata={}, asset_mentions=[] ) assert payload.has_assets is False payload.asset_mentions.append( AssetMention(asset_id="BTC", mention_span=(0,3), confidence=0.9, source_text="BTC", mention_type="ticker") ) assert payload.has_assets is True class TestSentimentScores: """Tests for SentimentScores schema""" def test_valid_creation(self): """Should create valid scores""" scores = SentimentScores( polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2 ) assert scores.polarity == 0.5 assert scores.confidence == 0.8 def test_polarity_bounds(self): """Polarity should be in [-1, 1]""" with pytest.raises(ValidationError): SentimentScores( polarity=1.5, confidence=0.5, positive_prob=0.5, negative_prob=0.2, neutral_prob=0.3 ) def test_confidence_bounds(self): """Confidence should be in [0, 1]""" with pytest.raises(ValidationError): SentimentScores( polarity=0.5, confidence=1.5, positive_prob=0.5, negative_prob=0.2, neutral_prob=0.3 ) def test_probabilities_sum(self): """Probabilities should be in [0, 1]""" scores = SentimentScores( polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2 ) assert 0 <= scores.positive_prob <= 1 assert 0 <= scores.negative_prob <= 1 assert 0 <= scores.neutral_prob <= 1 class TestEmotionScores: """Tests for EmotionScores schema""" def test_valid_creation(self): """Should create valid emotion scores""" scores = EmotionScores( joy=0.8, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=0.8 ) assert scores.joy == 0.8 assert scores.intensity == 0.8 def test_emotion_bounds(self): """All emotions should be in [0, 1]""" with pytest.raises(ValidationError): EmotionScores( joy=1.5, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=0.8 ) def test_intensity_bounds(self): """Intensity should be in [0, 1]""" with pytest.raises(ValidationError): EmotionScores( joy=0.8, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=1.5 ) class TestEventClassification: """Tests for EventClassification schema""" def test_valid_creation(self): """Should create valid event classification""" event = EventClassification( event_type=EventType.LISTING, confidence=0.8, assets_involved=["BTC"], key_details={"matched_keywords": ["listing"]}, severity=0.5 ) assert event.event_type == EventType.LISTING assert event.confidence == 0.8 def test_confidence_bounds(self): """Confidence should be in [0, 1]""" with pytest.raises(ValidationError): EventClassification( event_type=EventType.LISTING, confidence=1.5, assets_involved=[], key_details={}, severity=0.5 ) def test_severity_bounds(self): """Severity should be in [0, 1]""" with pytest.raises(ValidationError): EventClassification( event_type=EventType.LISTING, confidence=0.8, assets_involved=[], key_details={}, severity=1.5 ) class TestTemporalAnchor: """Tests for TemporalAnchor schema""" def test_valid_creation(self): """Should create valid temporal anchor""" anchor = TemporalAnchor( event_time=None, time_horizon="immediate", is_breaking=True, is_scheduled=False, scheduled_time=None ) assert anchor.time_horizon == "immediate" assert anchor.is_breaking is True def test_time_horizon_values(self): """time_horizon should accept valid values""" for horizon in ["immediate", "near", "medium", "long"]: anchor = TemporalAnchor( event_time=None, time_horizon=horizon, is_breaking=False, is_scheduled=False, scheduled_time=None ) assert anchor.time_horizon == horizon def test_event_time_optional(self): """event_time should be optional""" anchor = TemporalAnchor( event_time=None, time_horizon="immediate", is_breaking=False, is_scheduled=False, scheduled_time=None ) assert anchor.event_time is None def test_scheduled_time_when_scheduled(self): """scheduled_time should be present when is_scheduled=True""" anchor = TemporalAnchor( event_time=None, time_horizon="near", is_breaking=False, is_scheduled=True, scheduled_time=1700000000.0 ) assert anchor.scheduled_time == 1700000000.0 class TestCredibilityScore: """Tests for CredibilityScore schema""" def test_compute_method(self): """compute classmethod should create valid score""" cred = CredibilityScore.compute( source_base=0.8, content_quality=0.7, engagement_authenticity=0.6, cross_source=0.5, historical=0.9 ) assert isinstance(cred, CredibilityScore) assert 0 <= cred.composite <= 1 assert cred.source_base == 0.8 def test_composite_formula(self): """Composite should match weighted formula""" cred = CredibilityScore.compute( source_base=1.0, content_quality=1.0, engagement_authenticity=1.0, cross_source=1.0, historical=1.0 ) expected = 0.3 + 0.25 + 0.2 + 0.15 + 0.1 assert cred.composite == min(1.0, expected) def test_composite_capped_at_one(self): """Composite should be capped at 1.0""" cred = CredibilityScore.compute( source_base=1.0, content_quality=1.0, engagement_authenticity=1.0, cross_source=1.0, historical=1.0 ) assert cred.composite <= 1.0 class TestProcessedItem: """Tests for ProcessedItem schema""" def test_valid_creation(self): """Should create valid processed item""" from sentiment_engine.schemas.processed import ( SentimentScores, EmotionScores, EventClassification, EventType, TemporalAnchor, CredibilityScore ) item = ProcessedItem( payload_id="test:123", source_id="test_source", source_type="news", ingest_ts=1700000000.0, publish_ts=1700000000.0, entities=[], sentiment_per_asset={}, emotions_per_asset={}, events=[], temporal=TemporalAnchor(event_time=None, time_horizon="immediate", is_breaking=False, is_scheduled=False, scheduled_time=None), credibility=CredibilityScore(source_base=0.5, content_quality=0.5, engagement_authenticity=0.5, cross_source_corroboration=0.0, historical_accuracy=0.5, composite=0.5), processed_ts=1700000000.0, processing_latency_ms=100.0, model_versions={} ) assert item.payload_id == "test:123" assert item.processing_latency_ms == 100.0 class TestOutputSchemas: """Tests for output schemas""" def test_asset_sentiment(self): """AssetSentiment should validate""" from sentiment_engine.schemas.output import AssetSentiment asset = AssetSentiment( asset_id="BTC", sentiment=SentimentScores(polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2), emotions=EmotionScores(joy=0.5, fear=0.1, anger=0.0, greed=0.3, sadness=0.0, intensity=0.5), events=[], mention_count=5 ) assert asset.asset_id == "BTC" def test_sentiment_output(self): """SentimentOutput should validate""" from sentiment_engine.schemas.output import SentimentOutput, AssetSentiment output = SentimentOutput( timestamp=1700000000.0, assets={"BTC": AssetSentiment(asset_id="BTC", sentiment=SentimentScores(polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2), emotions=EmotionScores(joy=0.5, fear=0.1, anger=0.0, greed=0.3, sadness=0.0, intensity=0.5), events=[], mention_count=5)}, market_fear_greed=50.0, global_sentiment=0.5 ) assert output.timestamp == 1700000000.0 class TestConnectorConfigs: """Tests for connector configuration schemas""" def test_base_connector_config(self): """Base connector config should validate""" config = ConnectorConfig( name="test", source_type="news", poll_interval_seconds=300, timeout_seconds=30 ) assert config.name == "test" assert config.poll_interval_seconds == 300 def test_rss_connector_config(self): """RSS connector config should validate""" config = RSSConnectorConfig( name="rss_test", source_type="news", feed_urls=["https://example.com/rss"], max_items_per_feed=50 ) assert config.feed_urls == ["https://example.com/rss"] def test_api_connector_config(self): """API connector config should validate""" config = APIConnectorConfig( name="api_test", source_type="news", base_url="https://api.example.com", endpoints=["/v1/news"] ) assert config.base_url == "https://api.example.com" def test_twitter_connector_config(self): """Twitter connector config should validate""" config = TwitterConnectorConfig( name="twitter_test", source_type="social", bearer_token="test_token" ) assert config.bearer_token == "test_token" def test_reddit_connector_config(self): """Reddit connector config should validate""" config = RedditConnectorConfig( name="reddit_test", source_type="social", client_id="test_id", client_secret="test_secret", subreddits=["CryptoCurrency"] ) assert "CryptoCurrency" in config.subreddits def test_rate_limits_bounds(self): """Rate limits should be positive""" with pytest.raises(ValidationError): ConnectorConfig( name="test", source_type="news", rate_limit_rps=-1 ) with pytest.raises(ValidationError): ConnectorConfig( name="test", source_type="news", rate_limit_rpm=0 ) def test_backoff_bounds(self): """Backoff parameters should be positive""" with pytest.raises(ValidationError): ConnectorConfig( name="test", source_type="news", backoff_base_seconds=-1 ) if __name__ == "__main__": pytest.main([__file__, "-v"])