| 1 | """Tests for Truth Social source module.""" |
| 2 | import unittest |
| 3 | from unittest.mock import patch, MagicMock |
| 4 | |
| 5 | from lib import truthsocial |
| 6 | |
| 7 | |
| 8 | class TestStripHtml(unittest.TestCase): |
| 9 | """Test HTML tag stripping.""" |
| 10 | |
| 11 | def test_basic_paragraph(self): |
| 12 | self.assertEqual(truthsocial._strip_html("<p>Hello world</p>"), "Hello world") |
| 13 | |
| 14 | def test_br_tags(self): |
| 15 | self.assertEqual(truthsocial._strip_html("Line 1<br>Line 2"), "Line 1\nLine 2") |
| 16 | self.assertEqual(truthsocial._strip_html("Line 1<br/>Line 2"), "Line 1\nLine 2") |
| 17 | self.assertEqual(truthsocial._strip_html("Line 1<br />Line 2"), "Line 1\nLine 2") |
| 18 | |
| 19 | def test_nested_tags(self): |
| 20 | self.assertEqual(truthsocial._strip_html("<p>Hello <a href='#'>world</a></p>"), "Hello world") |
| 21 | |
| 22 | def test_empty_string(self): |
| 23 | self.assertEqual(truthsocial._strip_html(""), "") |
| 24 | |
| 25 | def test_no_tags(self): |
| 26 | self.assertEqual(truthsocial._strip_html("plain text"), "plain text") |
| 27 | |
| 28 | def test_entities_preserved(self): |
| 29 | self.assertEqual(truthsocial._strip_html("<p>& test</p>"), "& test") |
| 30 | |
| 31 | |
| 32 | class TestExtractCoreSubject(unittest.TestCase): |
| 33 | """Test query preprocessing.""" |
| 34 | |
| 35 | def test_strips_question_prefix(self): |
| 36 | self.assertEqual(truthsocial._extract_core_subject("what are people saying about tariffs"), "tariffs") |
| 37 | |
| 38 | def test_strips_noise_words(self): |
| 39 | self.assertEqual(truthsocial._extract_core_subject("latest trending crypto news"), "crypto") |
| 40 | |
| 41 | def test_preserves_core_topic(self): |
| 42 | self.assertEqual(truthsocial._extract_core_subject("tariffs"), "tariffs") |
| 43 | |
| 44 | def test_strips_trailing_punctuation(self): |
| 45 | self.assertEqual(truthsocial._extract_core_subject("what is bitcoin?"), "bitcoin") |
| 46 | |
| 47 | |
| 48 | class TestParseDate(unittest.TestCase): |
| 49 | """Test date parsing from Mastodon status.""" |
| 50 | |
| 51 | def test_iso_date(self): |
| 52 | self.assertEqual(truthsocial._parse_date({"created_at": "2026-03-09T12:00:00.000Z"}), "2026-03-09") |
| 53 | |
| 54 | def test_missing_date(self): |
| 55 | self.assertIsNone(truthsocial._parse_date({})) |
| 56 | |
| 57 | def test_short_date(self): |
| 58 | self.assertIsNone(truthsocial._parse_date({"created_at": "short"})) |
| 59 | |
| 60 | def test_none_value(self): |
| 61 | self.assertIsNone(truthsocial._parse_date({"created_at": None})) |
| 62 | |
| 63 | |
| 64 | class TestDepthConfig(unittest.TestCase): |
| 65 | """Test depth configuration.""" |
| 66 | |
| 67 | def test_all_depths_exist(self): |
| 68 | self.assertIn("quick", truthsocial.DEPTH_CONFIG) |
| 69 | self.assertIn("default", truthsocial.DEPTH_CONFIG) |
| 70 | self.assertIn("deep", truthsocial.DEPTH_CONFIG) |
| 71 | |
| 72 | def test_depth_ordering(self): |
| 73 | self.assertLess(truthsocial.DEPTH_CONFIG["quick"], truthsocial.DEPTH_CONFIG["default"]) |
| 74 | self.assertLess(truthsocial.DEPTH_CONFIG["default"], truthsocial.DEPTH_CONFIG["deep"]) |
| 75 | |
| 76 | |
| 77 | class TestSearchTruthSocial(unittest.TestCase): |
| 78 | """Test search function auth handling.""" |
| 79 | |
| 80 | def test_no_config_returns_error(self): |
| 81 | result = truthsocial.search_truthsocial("test", "2026-02-09", "2026-03-09") |
| 82 | self.assertEqual(result["statuses"], []) |
| 83 | self.assertIn("not configured", result["error"]) |
| 84 | |
| 85 | def test_empty_token_returns_error(self): |
| 86 | result = truthsocial.search_truthsocial( |
| 87 | "test", "2026-02-09", "2026-03-09", |
| 88 | config={"TRUTHSOCIAL_TOKEN": ""}, |
| 89 | ) |
| 90 | self.assertEqual(result["statuses"], []) |
| 91 | self.assertIn("not configured", result["error"]) |
| 92 | |
| 93 | @patch("lib.truthsocial.http.request") |
| 94 | def test_search_sends_browser_like_headers(self, mock_request): |
| 95 | """Regression for #909: Cloudflare 403s the skill's default |
| 96 | User-Agent regardless of token validity; the request must include |
| 97 | browser-like headers alongside Authorization.""" |
| 98 | from lib import http as http_module |
| 99 | mock_request.return_value = {"statuses": []} |
| 100 | truthsocial.search_truthsocial( |
| 101 | "health policy", "2026-02-09", "2026-03-09", |
| 102 | config={"TRUTHSOCIAL_TOKEN": "valid_token"}, |
| 103 | ) |
| 104 | _, kwargs = mock_request.call_args |
| 105 | headers = kwargs["headers"] |
| 106 | self.assertEqual(headers["Authorization"], "Bearer valid_token") |
| 107 | self.assertEqual(headers["User-Agent"], http_module.BROWSER_USER_AGENT) |
| 108 | self.assertIn("Accept", headers) |
| 109 | self.assertIn("Accept-Language", headers) |
| 110 | self.assertEqual(headers["Referer"], "https://truthsocial.com/") |
| 111 | |
| 112 | @patch("lib.truthsocial.http.request") |
| 113 | def test_401_returns_token_expired(self, mock_request): |
| 114 | from lib.http import HTTPError |
| 115 | mock_request.side_effect = HTTPError("Unauthorized", status_code=401) |
| 116 | result = truthsocial.search_truthsocial( |
| 117 | "test", "2026-02-09", "2026-03-09", |
| 118 | config={"TRUTHSOCIAL_TOKEN": "expired_token"}, |
| 119 | ) |
| 120 | self.assertEqual(result["statuses"], []) |
| 121 | self.assertIn("expired", result["error"]) |
| 122 | |
| 123 | @patch("lib.truthsocial.http.request") |
| 124 | def test_403_returns_access_denied(self, mock_request): |
| 125 | from lib.http import HTTPError |
| 126 | mock_request.side_effect = HTTPError("Forbidden", status_code=403) |
| 127 | result = truthsocial.search_truthsocial( |
| 128 | "test", "2026-02-09", "2026-03-09", |
| 129 | config={"TRUTHSOCIAL_TOKEN": "blocked_token"}, |
| 130 | ) |
| 131 | self.assertEqual(result["statuses"], []) |
| 132 | self.assertIn("Cloudflare", result["error"]) |
| 133 | |
| 134 | @patch("lib.truthsocial.http.request") |
| 135 | def test_429_returns_rate_limited(self, mock_request): |
| 136 | from lib.http import HTTPError |
| 137 | mock_request.side_effect = HTTPError("Too Many Requests", status_code=429) |
| 138 | result = truthsocial.search_truthsocial( |
| 139 | "test", "2026-02-09", "2026-03-09", |
| 140 | config={"TRUTHSOCIAL_TOKEN": "rate_limited_token"}, |
| 141 | ) |
| 142 | self.assertEqual(result["statuses"], []) |
| 143 | self.assertIn("rate limited", result["error"]) |
| 144 | |
| 145 | @patch("lib.truthsocial.http.request") |
| 146 | def test_successful_search(self, mock_request): |
| 147 | mock_request.return_value = { |
| 148 | "statuses": [ |
| 149 | { |
| 150 | "content": "<p>Test post about tariffs</p>", |
| 151 | "created_at": "2026-03-09T12:00:00.000Z", |
| 152 | "url": "https://truthsocial.com/@user/123", |
| 153 | "account": {"acct": "user", "display_name": "Test User"}, |
| 154 | "favourites_count": 10, |
| 155 | "reblogs_count": 5, |
| 156 | "replies_count": 3, |
| 157 | } |
| 158 | ] |
| 159 | } |
| 160 | result = truthsocial.search_truthsocial( |
| 161 | "tariffs", "2026-02-09", "2026-03-09", |
| 162 | config={"TRUTHSOCIAL_TOKEN": "valid_token"}, |
| 163 | ) |
| 164 | self.assertEqual(len(result["statuses"]), 1) |
| 165 | # Verify bearer token was passed |
| 166 | call_args = mock_request.call_args |
| 167 | self.assertEqual(call_args[1]["headers"]["Authorization"], "Bearer valid_token") |
| 168 | |
| 169 | |
| 170 | class TestParseTruthSocialResponse(unittest.TestCase): |
| 171 | """Test response parsing.""" |
| 172 | |
| 173 | def test_basic_post(self): |
| 174 | response = { |
| 175 | "statuses": [ |
| 176 | { |
| 177 | "content": "<p>Hello from Truth Social</p>", |
| 178 | "created_at": "2026-03-09T12:00:00.000Z", |
| 179 | "url": "https://truthsocial.com/@testuser/456", |
| 180 | "account": {"acct": "testuser", "display_name": "Test User"}, |
| 181 | "favourites_count": 100, |
| 182 | "reblogs_count": 50, |
| 183 | "replies_count": 25, |
| 184 | } |
| 185 | ] |
| 186 | } |
| 187 | items = truthsocial.parse_truthsocial_response(response) |
| 188 | self.assertEqual(len(items), 1) |
| 189 | item = items[0] |
| 190 | self.assertEqual(item["handle"], "testuser") |
| 191 | self.assertEqual(item["display_name"], "Test User") |
| 192 | self.assertEqual(item["text"], "Hello from Truth Social") |
| 193 | self.assertEqual(item["url"], "https://truthsocial.com/@testuser/456") |
| 194 | self.assertEqual(item["date"], "2026-03-09") |
| 195 | self.assertEqual(item["engagement"]["likes"], 100) |
| 196 | self.assertEqual(item["engagement"]["reposts"], 50) |
| 197 | self.assertEqual(item["engagement"]["replies"], 25) |
| 198 | self.assertGreater(item["relevance"], 0) |
| 199 | |
| 200 | def test_empty_response(self): |
| 201 | items = truthsocial.parse_truthsocial_response({"statuses": []}) |
| 202 | self.assertEqual(items, []) |
| 203 | |
| 204 | def test_missing_fields(self): |
| 205 | response = { |
| 206 | "statuses": [ |
| 207 | { |
| 208 | "content": "", |
| 209 | "account": {}, |
| 210 | } |
| 211 | ] |
| 212 | } |
| 213 | items = truthsocial.parse_truthsocial_response(response) |
| 214 | self.assertEqual(len(items), 1) |
| 215 | self.assertEqual(items[0]["handle"], "") |
| 216 | self.assertEqual(items[0]["text"], "") |
| 217 | self.assertEqual(items[0]["engagement"]["likes"], 0) |
| 218 | |
| 219 | def test_relevance_ordering(self): |
| 220 | response = { |
| 221 | "statuses": [ |
| 222 | {"content": "<p>First</p>", "account": {"acct": "a"}, "favourites_count": 10, "reblogs_count": 0, "replies_count": 0}, |
| 223 | {"content": "<p>Second</p>", "account": {"acct": "b"}, "favourites_count": 5, "reblogs_count": 0, "replies_count": 0}, |
| 224 | {"content": "<p>Third</p>", "account": {"acct": "c"}, "favourites_count": 1, "reblogs_count": 0, "replies_count": 0}, |
| 225 | ] |
| 226 | } |
| 227 | items = truthsocial.parse_truthsocial_response(response) |
| 228 | self.assertGreaterEqual(items[0]["relevance"], items[1]["relevance"]) |
| 229 | self.assertGreaterEqual(items[1]["relevance"], items[2]["relevance"]) |
| 230 | |
| 231 | def test_html_stripping_in_parse(self): |
| 232 | response = { |
| 233 | "statuses": [ |
| 234 | { |
| 235 | "content": "<p>Hello <a href='https://example.com'>@user</a> check this out<br/>New line</p>", |
| 236 | "account": {"acct": "poster"}, |
| 237 | "favourites_count": 0, |
| 238 | "reblogs_count": 0, |
| 239 | "replies_count": 0, |
| 240 | } |
| 241 | ] |
| 242 | } |
| 243 | items = truthsocial.parse_truthsocial_response(response) |
| 244 | self.assertNotIn("<", items[0]["text"]) |
| 245 | self.assertNotIn(">", items[0]["text"]) |
| 246 | |
| 247 | if __name__ == "__main__": |
| 248 | unittest.main() |
| 249 |