"""Tests for marketing_intelligence.mcp.tools.storage StorageService logic.""" import asyncio from collections.abc import Coroutine from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from marketing_intelligence.mcp.tools.responses import ( PersistResponse, RunRecordResponse, StoreResponse, ) from marketing_intelligence.mcp.tools.storage import StorageService def _run(coro: Coroutine[Any, Any, Any]) -> Any: return asyncio.run(coro) def _make_svc() -> tuple[StorageService, MagicMock]: """Create a StorageService with a mocked backend.""" mock_backend = MagicMock() mock_backend.write_post_metrics = AsyncMock() mock_backend.write_latest_metrics = AsyncMock() mock_backend.write_run_record = AsyncMock() mock_backend.write_campaign_post = AsyncMock() mock_backend.write_artist_snapshot = AsyncMock() mock_backend.write_sentiment = AsyncMock() mock_backend.write_video_comments = AsyncMock() with patch( "marketing_intelligence.mcp.tools.storage.get_backend", return_value=mock_backend, ): svc = StorageService() return svc, mock_backend class TestPersistMetrics: def test_success_no_video_ids(self) -> None: svc, backend = _make_svc() r = _run(svc.persist_metrics([{"views": 100}], "ck1_ak1", "run1")) assert isinstance(r, PersistResponse) assert r.success is True assert r.count == 1 backend.write_post_metrics.assert_awaited_once() backend.write_latest_metrics.assert_not_awaited() def test_writes_latest_when_video_id_present(self) -> None: svc, backend = _make_svc() items = [{"video_id": "vid1", "views": 500, "likes": 10}] r = _run(svc.persist_metrics(items, "ck1_ak1", "run1")) assert r.success is True backend.write_latest_metrics.assert_awaited_once() call_key_arg, latest_arg = backend.write_latest_metrics.call_args[0] assert call_key_arg == "ck1_ak1" assert "vid1" in latest_arg assert latest_arg["vid1"]["views"] == 500 def test_uses_post_id_as_key_when_no_video_id(self) -> None: svc, backend = _make_svc() items = [{"post_id": "p1", "views": 100}] _run(svc.persist_metrics(items, "ck1_ak1", "run1")) backend.write_latest_metrics.assert_awaited_once() call_key = list(backend.write_latest_metrics.call_args[0][1].keys())[0] assert call_key == "p1" def test_backend_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_post_metrics.side_effect = OSError("disk full") r = _run(svc.persist_metrics([{"video_id": "v1"}], "ck1_ak1", "run1")) assert r.success is False assert "disk full" in (r.error or "") def test_mission_and_count(self) -> None: svc, backend = _make_svc() items = [{"video_id": "v1"}, {"video_id": "v2"}] r = _run(svc.persist_metrics(items, "key1", "run1")) assert r.mission == "key1" assert r.count == 2 def test_multiple_items_only_those_with_ids_in_latest(self) -> None: svc, backend = _make_svc() items: list[dict[str, Any]] = [ {"video_id": "v1", "views": 100}, {"views": 50}, # no id — excluded from latest ] _run(svc.persist_metrics(items, "ck1_ak1", "run1")) latest_arg = backend.write_latest_metrics.call_args[0][1] assert "v1" in latest_arg assert len(latest_arg) == 1 def test_error_truncated_to_300_chars(self) -> None: svc, backend = _make_svc() backend.write_post_metrics.side_effect = OSError("x" * 500) r = _run(svc.persist_metrics([], "k", "r")) assert len(r.error or "") == 300 class TestRunRecord: def test_success(self) -> None: svc, backend = _make_svc() r = _run(svc.run_record("run1", "discovery", campaign_key="camp1")) assert isinstance(r, RunRecordResponse) assert r.success is True assert r.run_id == "run1" backend.write_run_record.assert_awaited_once() def test_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_run_record.side_effect = ValueError("bad value") r = _run(svc.run_record("run1", "discovery")) assert r.success is False assert "bad value" in (r.error or "") def test_defaults_started_at_when_none(self) -> None: svc, backend = _make_svc() _run(svc.run_record("run1", "discovery")) record = backend.write_run_record.call_args[0][0] assert record.started_at is not None def test_uses_provided_started_at(self) -> None: svc, backend = _make_svc() _run(svc.run_record("run1", "discovery", started_at="2024-01-01T00:00:00Z")) record = backend.write_run_record.call_args[0][0] assert record.started_at == "2024-01-01T00:00:00Z" def test_run_id_passed_to_record(self) -> None: svc, backend = _make_svc() _run(svc.run_record("run_xyz", "discovery")) record = backend.write_run_record.call_args[0][0] assert record.run_id == "run_xyz" def test_is_sample_flag(self) -> None: svc, backend = _make_svc() _run(svc.run_record("run1", "watch", is_sample=True, sample_size_target=50)) record = backend.write_run_record.call_args[0][0] assert record.is_sample is True assert record.sample_size_target == 50 class TestCampaignPost: def test_success_without_sound(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts"): r = _run(svc.campaign_post("p1", "ck1", "ak1", "camp1", "run1", 1)) assert isinstance(r, StoreResponse) assert r.success is True assert r.mission == "camp1" assert r.count == 1 def test_no_sound_record_when_no_sound_id(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts"): _run(svc.campaign_post("p1", "ck1", "ak1", "camp1", "run1", 1)) sound_arg = backend.write_campaign_post.call_args[0][1] assert sound_arg is None def test_success_with_sound(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts"): r = _run( svc.campaign_post( "p1", "ck1", "ak1", "camp1", "run1", 1, sound_id="s1", sound_title="Track", sound_url="https://t.co/s", ) ) assert r.success is True sound_record = backend.write_campaign_post.call_args[0][1] assert sound_record is not None assert sound_record.sound_id == "s1" assert sound_record.title == "Track" def test_backend_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_campaign_post.side_effect = OSError("write failed") r = _run(svc.campaign_post("p1", "ck1", "ak1", "camp1", "run1", 1)) assert r.success is False assert "write failed" in (r.error or "") def test_url_defaults_to_tiktok_video_url(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts") as mock_add: _run(svc.campaign_post("post123", "ck1", "ak1", "camp1", "run1", 1)) posts = mock_add.call_args[0][1] assert "post123" in posts[0].url def test_explicit_url_used_when_provided(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts") as mock_add: _run( svc.campaign_post( "p1", "ck1", "ak1", "camp1", "run1", 1, url="https://custom.url/video/p1", ) ) posts = mock_add.call_args[0][1] assert posts[0].url == "https://custom.url/video/p1" def test_add_posts_called_with_campaign_id(self) -> None: svc, backend = _make_svc() with patch("marketing_intelligence.mcp.tools.storage.add_posts") as mock_add: _run(svc.campaign_post("p1", "ck1", "ak1", "camp1", "run1", 1)) assert mock_add.call_args[0][0] == "camp1" class TestArtistSnapshot: def test_success(self) -> None: svc, backend = _make_svc() r = _run(svc.artist_snapshot("ak1", "camp1", "run1", tiktok_followers=5000)) assert isinstance(r, StoreResponse) assert r.success is True def test_followers_stored_in_record(self) -> None: svc, backend = _make_svc() _run(svc.artist_snapshot("ak1", "camp1", "run1", tiktok_followers=10000)) record = backend.write_artist_snapshot.call_args[0][0] assert record.tiktok_followers == 10000 def test_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_artist_snapshot.side_effect = TypeError("bad type") r = _run(svc.artist_snapshot("ak1", "camp1", "run1")) assert r.success is False assert "bad type" in (r.error or "") def test_mission_is_campaign_id(self) -> None: svc, backend = _make_svc() r = _run(svc.artist_snapshot("ak1", "camp99", "run1")) assert r.mission == "camp99" class TestCampaignSentiment: def _run_sentiment(self, svc: StorageService, **kwargs: Any) -> StoreResponse: defaults: dict[str, Any] = dict( campaign_config_key="ck1_ak1", run_id="run1", sentiment="positive", confidence=0.9, summary="great", reasoning="many likes", how_sound_is_used="background", confidence_rationale="strong signal", ) defaults.update(kwargs) result: StoreResponse = _run(svc.campaign_sentiment(**defaults)) return result def test_success(self) -> None: svc, backend = _make_svc() r = self._run_sentiment(svc) assert isinstance(r, StoreResponse) assert r.success is True backend.write_sentiment.assert_awaited_once() def test_trend_signals_converted(self) -> None: svc, backend = _make_svc() self._run_sentiment( svc, trend_signals=[ { "signal": "trending", "evidence": "high views", "supporting_video_ids": ["v1"], } ], ) record = backend.write_sentiment.call_args[0][0] assert len(record.trend_signals) == 1 def test_optional_lists_default_to_empty(self) -> None: svc, backend = _make_svc() self._run_sentiment(svc) record = backend.write_sentiment.call_args[0][0] assert record.detected_topics == [] assert record.primary_hashtags == [] assert record.virality_factors == [] assert record.video_ids == [] def test_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_sentiment.side_effect = OSError("write failed") r = self._run_sentiment(svc) assert r.success is False def test_mission_is_campaign_config_key(self) -> None: svc, backend = _make_svc() r = self._run_sentiment(svc, campaign_config_key="ck2_ak2") assert r.mission == "ck2_ak2" class TestVideoComments: def test_success(self) -> None: svc, backend = _make_svc() r = _run( svc.video_comments( "vid1", "camp1", "run1", "https://t.co/v", ["nice", "wow"] ) ) assert isinstance(r, StoreResponse) assert r.success is True def test_comment_count_in_data(self) -> None: svc, backend = _make_svc() _run( svc.video_comments( "vid1", "camp1", "run1", "https://t.co/v", ["a", "b", "c"] ) ) data = backend.write_video_comments.call_args[0][3] assert data["comment_count"] == 3 assert data["comment_texts"] == ["a", "b", "c"] def test_optional_sentiment_stored(self) -> None: svc, backend = _make_svc() _run( svc.video_comments( "vid1", "camp1", "run1", "https://t.co/v", [], sentiment="positive", confidence=0.85, summary="great vibes", ) ) data = backend.write_video_comments.call_args[0][3] assert data["sentiment"] == "positive" assert data["confidence"] == 0.85 def test_error_returns_failure(self) -> None: svc, backend = _make_svc() backend.write_video_comments.side_effect = OSError("disk full") r = _run(svc.video_comments("vid1", "camp1", "run1", "https://t.co/v", [])) assert r.success is False assert "disk full" in (r.error or "") def test_mission_is_campaign_id(self) -> None: svc, backend = _make_svc() r = _run(svc.video_comments("v1", "camp99", "run1", "https://t.co/v", [])) assert r.mission == "camp99"