File size: 10,865 Bytes
36dada9
 
 
 
 
085d910
36dada9
 
 
 
 
 
085d910
36dada9
 
 
085d910
36dada9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
085d910
36dada9
085d910
 
36dada9
085d910
36dada9
 
 
 
 
 
 
 
 
 
 
 
085d910
36dada9
085d910
 
36dada9
085d910
 
 
 
36dada9
 
085d910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36dada9
 
 
 
 
 
 
 
 
 
 
 
 
085d910
36dada9
085d910
 
36dada9
085d910
 
36dada9
 
085d910
 
 
 
 
 
 
 
 
 
 
36dada9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89e5718
 
36dada9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89e5718
 
 
36dada9
 
 
 
 
 
 
085d910
36dada9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
from fastapi.testclient import TestClient

import TerraFin.data.cache.manager as cache_manager_module
import TerraFin.interface.watchlist_service as watchlist_service_module
from TerraFin.data.cache.registry import reset_cache_manager
from TerraFin.data.providers.private_access import PRIVATE_SERIES, clear_private_series_cache
from TerraFin.data.providers.private_access.client import PrivateAccessClient
from TerraFin.interface.server import create_app
from TerraFin.interface.watchlist_service import reset_watchlist_service


def _assert_watchlist_item_shape(item: dict) -> None:
    assert set(item) == {"symbol", "name", "move", "tags"}
    assert isinstance(item["symbol"], str)
    assert isinstance(item["name"], str)
    assert isinstance(item["move"], str)
    assert isinstance(item["tags"], list)


def _assert_breadth_metric_shape(metric: dict) -> None:
    assert set(metric) == {"label", "value", "tone"}
    assert isinstance(metric["label"], str)
    assert isinstance(metric["value"], str)
    assert isinstance(metric["tone"], str)


def _reset_services() -> None:
    reset_cache_manager()
    reset_watchlist_service()


def test_dashboard_data_uses_fallback_when_private_source_unconfigured(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    monkeypatch.delenv("TERRAFIN_MONGODB_URI", raising=False)
    monkeypatch.delenv("MONGODB_URI", raising=False)
    _reset_services()
    client = TestClient(create_app())

    watchlist_response = client.get("/dashboard/api/watchlist")
    assert watchlist_response.status_code == 200
    watchlist_payload = watchlist_response.json()
    assert isinstance(watchlist_payload["items"], list)
    assert watchlist_payload["backendConfigured"] is False
    assert watchlist_payload["mode"] == "fallback"
    assert len(watchlist_payload["items"]) >= 7
    _assert_watchlist_item_shape(watchlist_payload["items"][0])

    breadth_response = client.get("/dashboard/api/market-breadth")
    assert breadth_response.status_code == 200
    breadth_payload = breadth_response.json()
    assert isinstance(breadth_payload["metrics"], list)
    assert len(breadth_payload["metrics"]) >= 1
    _assert_breadth_metric_shape(breadth_payload["metrics"][0])


def test_dashboard_market_breadth_uses_private_source_when_available(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)

    def _mock_panel(self, resource):
        _ = self
        assert resource == "market-breadth"
        return {"metrics": [{"label": "Advancers", "value": "500", "tone": "#047857"}]}

    monkeypatch.setattr(PrivateAccessClient, "fetch_panel", _mock_panel)
    _reset_services()

    client = TestClient(create_app())
    breadth_payload = client.get("/dashboard/api/market-breadth").json()

    _assert_breadth_metric_shape(breadth_payload["metrics"][0])
    assert breadth_payload["metrics"][0]["value"] == "500"


def test_dashboard_fear_greed_falls_back_to_cached_history_when_current_misses(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    _reset_services()
    clear_private_series_cache(PRIVATE_SERIES["fear_greed"])

    def _mock_history(self, key):
        _ = self, key
        return [
            {"time": "2026-01-01", "close": 25},
            {"time": "2026-01-10", "close": 40},
            {"time": "2026-01-28", "close": 62},
            {"time": "2026-02-01", "close": 70},
        ]

    def _mock_current(self, key):
        _ = self, key
        return {
            "name": "Fear & Greed",
            "value": 70,
            "as_of": "2026-02-01",
            "rating": "Greed",
            "metadata": {
                "score": 70,
                "rating": "Greed",
                "timestamp": "2026-02-01",
                "previous_close": 62,
                "previous_1_week": 40,
                "previous_1_month": 25,
            },
        }

    monkeypatch.setattr(PrivateAccessClient, "fetch_series_history", _mock_history)
    monkeypatch.setattr(PrivateAccessClient, "fetch_series_current", _mock_current)
    client = TestClient(create_app())
    payload = client.get("/dashboard/api/fear-greed").json()

    assert payload["score"] == 70
    assert payload["rating"] == "Greed"
    assert payload["previous_close"] == 62
    assert payload["previous_1_week"] == 40
    assert payload["previous_1_month"] == 25


def test_dashboard_cape_falls_back_to_series_history_when_current_misses(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    _reset_services()
    clear_private_series_cache(PRIVATE_SERIES["cape"])

    def _mock_history(self, key):
        _ = self, key
        return [
            {"time": "2025-12-01", "close": 29.4},
            {"time": "2026-01-01", "close": 31.1},
        ]

    def _mock_current(self, key):
        _ = self, key
        return {
            "name": "CAPE",
            "value": 31.1,
            "as_of": "2026-01-01",
            "metadata": {"date": "2026-01", "cape": 31.1},
        }

    monkeypatch.setattr(PrivateAccessClient, "fetch_series_history", _mock_history)
    monkeypatch.setattr(PrivateAccessClient, "fetch_series_current", _mock_current)
    client = TestClient(create_app())
    payload = client.get("/dashboard/api/cape").json()

    assert payload["date"] == "2026-01"
    assert payload["cape"] == 31.1


def test_dashboard_watchlist_crud(monkeypatch, tmp_path) -> None:
    storage: dict[str, dict] = {}

    class _FakeCollection:
        def find_one(self, query):
            return storage.get(query["_id"])

        def update_one(self, query, update, upsert=False):
            _ = upsert
            storage[query["_id"]] = {"_id": query["_id"], **update["$set"]}

    class _FakeDatabase:
        def __getitem__(self, name):
            _ = name
            return _FakeCollection()

    class _FakeMongoClient:
        def __init__(self, uri, serverSelectionTimeoutMS=2000):
            self.uri = uri
            _ = serverSelectionTimeoutMS

        def __getitem__(self, name):
            _ = name
            return _FakeDatabase()

    monkeypatch.setenv("TERRAFIN_MONGODB_URI", "mongodb://example.test")
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    import TerraFin.data.providers.market.ticker_info as _ticker_info_module
    monkeypatch.setattr(_ticker_info_module, "get_ticker_info", lambda symbol: {"currentPrice": 102.0, "previousClose": 100.0})
    monkeypatch.setattr(watchlist_service_module, "_resolve_company_name", lambda symbol: f"{symbol} Holdings")
    monkeypatch.setattr(watchlist_service_module, "MongoClient", _FakeMongoClient)

    _reset_services()
    client = TestClient(create_app())

    initial = client.get("/dashboard/api/watchlist")
    assert initial.status_code == 200
    assert initial.json()["items"] == []
    assert initial.json()["backendConfigured"] is True
    assert initial.json()["mode"] == "mongo"
    assert storage["terrafin_watchlist"] == {
        "_id": "terrafin_watchlist",
        "Company List": [],
        "items": [],
        "explicit_groups": [],
        "group_order": [],
        "item_order": {},
    }

    created = client.post("/dashboard/api/watchlist", json={"symbol": "meta"})
    assert created.status_code == 200
    created_payload = created.json()
    assert created_payload["backendConfigured"] is True
    assert created_payload["mode"] == "mongo"
    assert created_payload["items"] == [{"symbol": "META", "name": "META Holdings", "move": "+2.00%", "tags": []}]

    duplicate = client.post("/dashboard/api/watchlist", json={"symbol": "META"})
    assert duplicate.status_code == 409

    removed = client.delete("/dashboard/api/watchlist/META")
    assert removed.status_code == 200
    assert removed.json()["items"] == []


def test_dashboard_watchlist_falls_back_when_mongo_backend_is_unreachable(monkeypatch, tmp_path) -> None:
    class _UnavailableCollection:
        def find_one(self, query):
            _ = query
            raise RuntimeError("mongo unavailable")

        def update_one(self, query, update, upsert=False):
            _ = query, update, upsert
            raise RuntimeError("mongo unavailable")

    class _UnavailableDatabase:
        def __getitem__(self, name):
            _ = name
            return _UnavailableCollection()

    class _UnavailableMongoClient:
        def __init__(self, uri, serverSelectionTimeoutMS=2000):
            _ = uri, serverSelectionTimeoutMS

        def __getitem__(self, name):
            _ = name
            return _UnavailableDatabase()

    monkeypatch.setenv("TERRAFIN_MONGODB_URI", "mongodb://unavailable.test")
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    monkeypatch.setattr(watchlist_service_module, "MongoClient", _UnavailableMongoClient)

    _reset_services()
    client = TestClient(create_app())

    response = client.get("/dashboard/api/watchlist")
    assert response.status_code == 200
    payload = response.json()
    assert payload["backendConfigured"] is False
    assert payload["mode"] == "fallback"
    assert len(payload["items"]) >= 1


def test_dashboard_cache_status_endpoint(tmp_path, monkeypatch) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    _reset_services()
    client = TestClient(create_app())
    response = client.get("/dashboard/api/cache-status")
    assert response.status_code == 200
    body = response.json()
    assert "sources" in body
    assert isinstance(body["sources"], list)
    assert len(body["sources"]) >= 3
    first = body["sources"][0]
    assert {
        "source",
        "mode",
        "intervalSeconds",
        "enabled",
        "lastRunAt",
        "lastSuccessAt",
        "lastError",
    }.issubset(first.keys())
    assert any(item["source"] == "portfolio.cache" for item in body["sources"])


def test_watchlist_page_route_serves_frontend(tmp_path, monkeypatch) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    _reset_services()
    client = TestClient(create_app())
    response = client.get("/watchlist")
    assert response.status_code == 200
    assert response.headers["content-type"].startswith("text/html")


def test_dashboard_cache_refresh_endpoint(tmp_path, monkeypatch) -> None:
    monkeypatch.setattr(cache_manager_module, "_FILE_CACHE_DIR", tmp_path)
    _reset_services()
    client = TestClient(create_app())
    response = client.post("/dashboard/api/cache-refresh?force=true")
    assert response.status_code == 200
    body = response.json()
    assert body["ok"] is True
    assert body["force"] is True
    assert isinstance(body["sources"], list)
    assert len(body["sources"]) >= 3