"""Tests for marketing_intelligence.storage.local and factory.""" import asyncio import json from pathlib import Path from typing import Any from unittest.mock import patch import pytest from marketing_intelligence.core.models import ( ArtistSnapshotRecord, CampaignConfigRecord, CampaignSentiment, RunRecord, SoundRecord, ) from marketing_intelligence.storage.local import LocalBackend, _write # ─────────────────────────── helpers ─────────────────────────────────────── @pytest.fixture() def backend(tmp_path: Path) -> Any: with patch("marketing_intelligence.storage.local.settings") as s: s.output_dir = str(tmp_path) yield LocalBackend() # ─────────────────────────── _write helper ───────────────────────────────── class TestWriteHelper: def test_creates_parent_dirs(self, tmp_path: Path) -> None: path = tmp_path / "a" / "b" / "c.json" result = _write(path, {"x": 1}) assert path.exists() assert result == str(path) def test_content_is_valid_json(self, tmp_path: Path) -> None: path = tmp_path / "out.json" _write(path, {"key": "value"}) assert json.loads(path.read_text()) == {"key": "value"} def test_non_ascii_preserved(self, tmp_path: Path) -> None: path = tmp_path / "out.json" _write(path, {"name": "Ärtiст"}) assert "Ärtiст" in path.read_text(encoding="utf-8") # ─────────────────────────── write_run_record ────────────────────────────── class TestWriteRunRecord: def test_creates_file(self, backend: LocalBackend, tmp_path: Path) -> None: record = RunRecord( run_id="run-001", run_type="discovery", campaign_key="artist_a", started_at="2026-07-06T10:00:00", ) path = asyncio.run(backend.write_run_record(record)) assert path is not None assert Path(path).exists() def test_file_contains_run_id(self, backend: LocalBackend, tmp_path: Path) -> None: record = RunRecord( run_id="run-002", run_type="watch", started_at="2026-07-06T10:00:00", ) path = asyncio.run(backend.write_run_record(record)) assert path is not None data = json.loads(Path(path).read_text()) assert data["run_id"] == "run-002" # ─────────────────────────── write_campaign_post ─────────────────────────── class TestWriteCampaignPost: def _config(self) -> CampaignConfigRecord: return CampaignConfigRecord( campaign_key="camp_a", artist_key="artist_a", post_id="vid-001", content_tier=1, ) def test_creates_file_without_sound( self, backend: LocalBackend, tmp_path: Path ) -> None: path = asyncio.run( backend.write_campaign_post(self._config(), None, "camp_a", "run-001") ) assert path is not None assert Path(path).exists() def test_includes_sound_when_provided( self, backend: LocalBackend, tmp_path: Path ) -> None: sound = SoundRecord( sound_id="snd-1", title="Track", first_seen_at="2026-07-06T10:00:00" ) path = asyncio.run( backend.write_campaign_post(self._config(), sound, "camp_a", "run-001") ) assert path is not None data = json.loads(Path(path).read_text()) assert "sound" in data assert data["sound"]["sound_id"] == "snd-1" def test_no_sound_key_when_none( self, backend: LocalBackend, tmp_path: Path ) -> None: path = asyncio.run( backend.write_campaign_post(self._config(), None, "camp_a", "run-001") ) assert path is not None data = json.loads(Path(path).read_text()) assert "sound" not in data # ─────────────────────────── write_post_metrics ──────────────────────────── class TestWritePostMetrics: def _items(self): return [ {"video_id": "v1", "views": 1000, "likes": 50}, {"video_id": "v2", "views": 2000, "likes": 100}, ] def test_returns_path(self, backend: LocalBackend) -> None: path = asyncio.run( backend.write_post_metrics(self._items(), "cckey", "run-001") ) assert path is not None def test_items_written(self, backend: LocalBackend) -> None: path = asyncio.run( backend.write_post_metrics(self._items(), "cckey", "run-001") ) assert path is not None data = json.loads(Path(path).read_text()) assert len(data["items"]) == 2 def test_empty_items_returns_none(self, backend: LocalBackend) -> None: result = asyncio.run(backend.write_post_metrics([], "cckey", "run-001")) assert result is None def test_skips_items_without_id(self, backend: LocalBackend) -> None: items: list[dict[str, Any]] = [{"views": 100}, {"video_id": "v1", "views": 200}] path = asyncio.run(backend.write_post_metrics(items, "cckey", "run-001")) assert path is not None data = json.loads(Path(path).read_text()) assert len(data["items"]) == 1 def test_reasoning_creates_result_file( self, backend: LocalBackend, tmp_path: Path ) -> None: asyncio.run( backend.write_post_metrics( self._items(), "cckey", "run-001", reasoning="some reasoning" ) ) result_path = tmp_path / "cckey" / "run-001" / "result.json" assert result_path.exists() # ─────────────────────────── write_artist_snapshot ───────────────────────── class TestWriteArtistSnapshot: def test_creates_file(self, backend: LocalBackend) -> None: record = ArtistSnapshotRecord( artist_key="artist_a", run_id="run-001", observed_at="2026-07-06T10:00:00", tiktok_followers=10000, ) path = asyncio.run(backend.write_artist_snapshot(record, "camp_a")) assert path is not None assert Path(path).exists() # ─────────────────────────── write_sentiment ─────────────────────────────── class TestWriteSentiment: def _record(self) -> CampaignSentiment: return CampaignSentiment( campaign_config_key="cckey", run_id="run-001", observed_at="2026-07-06T10:00:00", sentiment="positive", confidence=0.9, summary="Good vibes", reasoning="Many positive comments", how_sound_is_used="background", confidence_rationale="High agreement", ) def test_creates_file(self, backend: LocalBackend) -> None: path = asyncio.run(backend.write_sentiment(self._record())) assert path is not None assert Path(path).exists() def test_content_correct(self, backend: LocalBackend) -> None: path = asyncio.run(backend.write_sentiment(self._record())) assert path is not None data = json.loads(Path(path).read_text()) assert data["sentiment"] == "positive" assert data["confidence"] == 0.9 # ─────────────────────────── metrics snapshot ────────────────────────────── class TestMetricsSnapshot: def test_read_returns_empty_when_missing(self, backend: LocalBackend) -> None: result = asyncio.run(backend.read_latest_metrics("no_key")) assert result == {} def test_write_then_read(self, backend: LocalBackend) -> None: metrics = {"v1": {"views": 1000}} asyncio.run(backend.write_latest_metrics("cckey", metrics)) result = asyncio.run(backend.read_latest_metrics("cckey")) assert result == metrics def test_read_returns_empty_on_corrupt_file( self, backend: LocalBackend, tmp_path: Path ) -> None: path = tmp_path / "snapshots" / "cckey" / "latest.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text("not json") result = asyncio.run(backend.read_latest_metrics("cckey")) assert result == {} # ─────────────────────────── video comments ──────────────────────────────── class TestVideoComments: def _comment_data(self, video_id: str = "v1") -> dict: return { "video_id": video_id, "url": "https://tiktok.com/@artist/video/123", "track_name": "Track A", "comment_count": 2, "comment_texts": ["great!", "love it"], } def test_write_creates_file(self, backend: LocalBackend) -> None: path = asyncio.run( backend.write_video_comments( "camp_a", "run-001", "v1", self._comment_data() ) ) assert path is not None assert Path(path).exists() def test_read_returns_empty_when_no_folder(self, backend: LocalBackend) -> None: result = asyncio.run(backend.read_all_video_comments("camp_a", "no_run")) assert result == [] def test_read_returns_unscored_only(self, backend: LocalBackend) -> None: asyncio.run( backend.write_video_comments( "camp_a", "run-001", "v1", self._comment_data("v1") ) ) asyncio.run( backend.write_video_comments( "camp_a", "run-001", "v2", {**self._comment_data("v2"), "sentiment": "positive"}, ) ) results = asyncio.run(backend.read_all_video_comments("camp_a", "run-001")) assert len(results) == 1 assert results[0]["video_id"] == "v1" def test_read_respects_limit(self, backend: LocalBackend) -> None: for i in range(5): asyncio.run( backend.write_video_comments( "camp_a", "run-001", f"v{i}", self._comment_data(f"v{i}") ) ) results = asyncio.run( backend.read_all_video_comments("camp_a", "run-001", limit=3) ) assert len(results) == 3 def test_update_sentiment_returns_true(self, backend: LocalBackend) -> None: asyncio.run( backend.write_video_comments( "camp_a", "run-001", "v1", self._comment_data() ) ) result = asyncio.run( backend.update_video_comments_sentiment( "camp_a", "run-001", "v1", "positive", 0.85, "Good", ["trend"] ) ) assert result is True def test_update_sentiment_persists( self, backend: LocalBackend, tmp_path: Path ) -> None: asyncio.run( backend.write_video_comments( "camp_a", "run-001", "v1", self._comment_data() ) ) asyncio.run( backend.update_video_comments_sentiment( "camp_a", "run-001", "v1", "positive", 0.85, "Good", ["trend"] ) ) path = tmp_path / "comments" / "camp_a" / "run-001" / "v1.json" data = json.loads(path.read_text()) assert data["sentiment"] == "positive" assert data["confidence"] == 0.85 assert data["key_themes"] == ["trend"] def test_update_sentiment_returns_false_when_missing( self, backend: LocalBackend ) -> None: result = asyncio.run( backend.update_video_comments_sentiment( "camp_a", "run-001", "missing", None, None, None, None ) ) assert result is False def test_read_sentiments_returns_all(self, backend: LocalBackend) -> None: for i in range(3): asyncio.run( backend.write_video_comments( "camp_a", "run-001", f"v{i}", self._comment_data(f"v{i}") ) ) results = asyncio.run(backend.read_all_video_sentiments("camp_a", "run-001")) assert len(results) == 3 def test_read_sentiments_empty_when_no_folder(self, backend: LocalBackend) -> None: result = asyncio.run(backend.read_all_video_sentiments("camp_a", "no_run")) assert result == [] # ─────────────────────────── factory ─────────────────────────────────────── class TestFactory: def test_get_backend_returns_local_backend(self) -> None: import marketing_intelligence.storage.factory as fac from marketing_intelligence.storage.local import LocalBackend fac._backend = None backend = fac.get_backend() assert isinstance(backend, LocalBackend) def test_get_backend_returns_same_instance(self) -> None: import marketing_intelligence.storage.factory as fac fac._backend = None b1 = fac.get_backend() b2 = fac.get_backend() assert b1 is b2 def test_get_backend_returns_s3_backend(self) -> None: import marketing_intelligence.storage.factory as fac from unittest.mock import MagicMock, patch fac._backend = None mock_s3_instance = MagicMock() mock_s3_class = MagicMock(return_value=mock_s3_instance) with ( patch("marketing_intelligence.core.config.settings") as s, patch("marketing_intelligence.storage.s3.S3Backend", mock_s3_class), ): s.storage_backend = "s3" backend = fac.get_backend() assert backend is mock_s3_instance fac._backend = None