File size: 15,219 Bytes
50094a0
 
 
 
 
 
 
 
33a3bf3
b1e6c81
50094a0
 
 
 
 
 
 
 
 
b1e6c81
50094a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33a3bf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50094a0
 
 
 
 
 
 
b1e6c81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260fe4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
from datetime import timedelta
from types import SimpleNamespace

import pytest

from finbot.analytics import today_in
from finbot.config import Settings
from finbot.dashboard import (
    authenticate,
    chat_turn,
    category_frame,
    coach_reply,
    load_dashboard,
    recent_frame,
    render_budgets,
    render_header,
    render_kpis,
    render_recurring,
)
from finbot.ingest import Ingestor
from finbot.models import Budget, Expense
from finbot.store import Store


def make_settings(tmp_path, **overrides):
    base = dict(
        data_dir=str(tmp_path),
        hf_dataset_repo="",
        hf_token="",
        telegram_bot_token="",
        telegram_webhook_secret="sekrit",
        allowed_telegram_user_ids="",
        allowed_telegram_usernames="",
        default_currency="INR",
        timezone="Asia/Kolkata",
    )
    base.update(overrides)
    return Settings(**base)


@pytest.fixture
def services(tmp_path):
    settings = make_settings(tmp_path)
    store = Store(settings)
    advisor = SimpleNamespace(advise=lambda **kw: f"advice about: {kw.get('question')}")
    yield SimpleNamespace(store=store, settings=settings, advisor=advisor)
    store.close()


def add(store, amount, category="food_dining", day=None, merchant="", currency="INR"):
    store.add_expense(
        Expense(
            user_id=1,
            amount_minor=amount,
            currency=currency,
            description=merchant or "thing",
            category=category,
            occurred_on=day or today_in("Asia/Kolkata"),
            merchant=merchant,
        )
    )


class TestUserRegistry:
    def test_records_and_reads_back(self, services):
        services.store.record_user(1, "alice", "Alice")
        assert services.store.username_for(1) == "alice"
        assert services.store.display_name_for(1) == "@alice"

    def test_strips_the_at_sign(self, services):
        services.store.record_user(1, "@bob")
        assert services.store.username_for(1) == "bob"

    def test_rename_updates_in_place(self, services):
        services.store.record_user(1, "alice")
        services.store.record_user(1, "alice2")
        assert services.store.username_for(1) == "alice2"
        # Crucially the id is unchanged, so the ledger is not orphaned.
        assert services.store.user_id_for("alice2") == 1

    def test_lookup_is_case_insensitive(self, services):
        services.store.record_user(1, "Alice")
        assert services.store.user_id_for("@ALICE") == 1

    def test_unknown_handle(self, services):
        assert services.store.user_id_for("nobody") is None
        assert services.store.user_id_for("") is None

    def test_falls_back_to_first_name_then_id(self, services):
        services.store.record_user(2, "", "Bob")
        assert services.store.display_name_for(2) == "Bob"
        assert services.store.display_name_for(999) == "999"


class TestUsernameAuthorisation:
    def test_username_allowlist(self, tmp_path):
        s = make_settings(tmp_path, allowed_telegram_usernames="alice, @bob")
        assert s.is_authorised(111, "alice")
        assert s.is_authorised(222, "@bob")
        assert not s.is_authorised(333, "carol")

    def test_case_insensitive(self, tmp_path):
        s = make_settings(tmp_path, allowed_telegram_usernames="Alice")
        assert s.is_authorised(1, "ALICE")

    def test_numeric_ids_still_work(self, tmp_path):
        s = make_settings(tmp_path, allowed_telegram_user_ids="42")
        assert s.is_authorised(42, "")

    def test_no_username_and_not_in_id_list(self, tmp_path):
        s = make_settings(tmp_path, allowed_telegram_usernames="alice")
        assert not s.is_authorised(1, "")

    def test_open_access_overrides(self, tmp_path):
        s = make_settings(tmp_path, open_access=True)
        assert s.is_authorised(999, "stranger")

    def test_missing_required_mentions_usernames(self, tmp_path):
        gaps = make_settings(tmp_path).missing_required()
        assert any("ALLOWED_TELEGRAM_USERNAMES" in g for g in gaps)

    def test_username_allowlist_satisfies_the_check(self, tmp_path):
        gaps = make_settings(
            tmp_path, allowed_telegram_usernames="alice"
        ).missing_required()
        assert not any("ALLOWED" in g for g in gaps)


class TestLoadDashboard:
    def test_empty_ledger(self, services):
        data = load_dashboard(services, 1)
        assert data.month_total == 0
        assert data.month_count == 0
        assert data.recent == []

    def test_totals_this_month(self, services):
        add(services.store, 25000)
        add(services.store, 15000, "transport")
        data = load_dashboard(services, 1)
        assert data.month_total == 40000
        assert data.month_count == 2

    def test_uses_the_display_name(self, services):
        services.store.record_user(1, "alice")
        assert load_dashboard(services, 1).display_name == "@alice"

    def test_category_breakdown_is_sorted(self, services):
        add(services.store, 10000, "food_dining")
        add(services.store, 50000, "rent")
        data = load_dashboard(services, 1)
        assert data.by_category[0][0] == "rent"

    def test_only_counts_the_dominant_currency(self, services):
        add(services.store, 10000, currency="INR")
        add(services.store, 20000, currency="INR")
        add(services.store, 999999, currency="USD")
        data = load_dashboard(services, 1)
        assert data.currency == "INR"
        assert data.month_total == 30000  # never sums across currencies

    def test_is_scoped_to_one_user(self, services):
        add(services.store, 25000)
        other = Expense(
            user_id=2, amount_minor=99999, currency="INR", description="x",
            category="other", occurred_on=today_in("Asia/Kolkata"),
        )
        services.store.add_expense(other)
        assert load_dashboard(services, 1).month_total == 25000

    def test_budgets_included(self, services):
        services.store.set_budget(Budget(1, "food_dining", 100000, "INR"))
        add(services.store, 25000)
        data = load_dashboard(services, 1)
        assert len(data.budgets) == 1
        assert data.budgets[0].spent_minor == 25000


class TestRendering:
    def test_header(self, services):
        services.store.record_user(1, "alice")
        assert "@alice" in render_header(load_dashboard(services, 1))

    def test_kpis_empty_state_is_helpful(self, services):
        assert "Nothing logged" in render_kpis(load_dashboard(services, 1))

    def test_kpis_show_the_total(self, services):
        add(services.store, 25000)
        assert "₹250.00" in render_kpis(load_dashboard(services, 1))

    def test_kpis_show_the_top_category(self, services):
        add(services.store, 25000, "groceries")
        assert "Groceries" in render_kpis(load_dashboard(services, 1))

    def test_category_frame_has_expected_columns(self, services):
        add(services.store, 25000)
        frame = category_frame(load_dashboard(services, 1))
        assert list(frame.columns) == ["Category", "Amount"]

    def test_category_frame_survives_an_empty_ledger(self, services):
        assert len(category_frame(load_dashboard(services, 1))) == 1

    def test_recent_frame(self, services):
        add(services.store, 25000, merchant="Blue Tokai")
        frame = recent_frame(load_dashboard(services, 1))
        assert "Blue Tokai" in frame["Description"].tolist()

    def test_recent_frame_empty(self, services):
        frame = recent_frame(load_dashboard(services, 1))
        assert "Nothing yet" in frame["Description"].tolist()

    def test_budgets_empty_state(self, services):
        assert "None set" in render_budgets(load_dashboard(services, 1))

    def test_budgets_rendered(self, services):
        services.store.set_budget(Budget(1, "rent", 100000, "INR"))
        assert "Rent" in render_budgets(load_dashboard(services, 1))

    def test_recurring_empty_state(self, services):
        assert "Nothing detected" in render_recurring(load_dashboard(services, 1))

    def test_recurring_rendered(self, services):
        today = today_in("Asia/Kolkata")
        for months_back in (0, 1, 2):
            day = (today.replace(day=1) - timedelta(days=31 * months_back)).replace(day=5)
            add(services.store, 64900, "subscriptions", day=day, merchant="Netflix")
        assert "Netflix" in render_recurring(load_dashboard(services, 1))


class TestAuthenticate:
    """The gate on the whole web view. Everything else trusts its answer."""

    def test_valid_token(self, services):
        from finbot.auth import issue

        token = issue(1, services.settings.telegram_webhook_secret)
        assert authenticate(services, token) == 1

    def test_no_token(self, services):
        assert authenticate(services, "") is None

    def test_garbage_token(self, services):
        assert authenticate(services, "not-a-token") is None

    def test_token_signed_with_another_secret(self, services):
        from finbot.auth import issue

        assert authenticate(services, issue(1, "someone-elses-secret")) is None

    def test_expired_token(self, services):
        import time

        from finbot.auth import issue

        stale = issue(
            1,
            services.settings.telegram_webhook_secret,
            ttl_seconds=1,
            now=int(time.time()) - 60,
        )
        assert authenticate(services, stale) is None

    def test_no_services_yet(self):
        # Before the runtime exists the page must not authenticate anyone.
        assert authenticate(None, "anything") is None

    def test_token_identifies_the_right_user(self, services):
        from finbot.auth import issue

        token = issue(4242, services.settings.telegram_webhook_secret)
        assert authenticate(services, token) == 4242


class TestCoach:
    def test_passes_the_question_through(self, services):
        add(services.store, 25000)
        assert "where can I cut back" in coach_reply(services, 1, "where can I cut back")

    def test_works_with_no_data(self, services):
        assert coach_reply(services, 1, "hello")


class TestChatTurn:
    """The chat box must be a peer of Telegram, not an answer-only sidebar.

    Regression: typing "food 800" into the dashboard chat silently did nothing
    and replied with generic advice, because every message went to the coach.
    """

    def _services(self, services):
        from finbot.features import default_features
        from finbot.registry import FeatureRegistry

        reg = FeatureRegistry()
        reg.register_all(default_features())
        services.registry = reg
        services.pending = {}
        services.llm = SimpleNamespace(enabled=False)
        services.ingestor = Ingestor(services.llm, "INR")
        return services

    def test_plain_text_logs_an_expense(self, services):
        s = self._services(services)
        answer = chat_turn(s, 1, "food 800")
        assert "800" in answer
        assert s.store.count(1) == 1

    def test_logged_expense_has_the_right_amount(self, services):
        s = self._services(services)
        chat_turn(s, 1, "food 800")
        assert s.store.list_expenses(1)[0].amount_minor == 80000

    def test_commands_work(self, services):
        s = self._services(services)
        chat_turn(s, 1, "coffee 250")
        assert "250" in chat_turn(s, 1, "/month")

    def test_unknown_command_is_reported_not_coached(self, services):
        s = self._services(services)
        assert "don't know" in chat_turn(s, 1, "/save good food 800")

    def test_html_is_stripped_for_the_chat_bubble(self, services):
        s = self._services(services)
        answer = chat_turn(s, 1, "food 800")
        assert "<" not in answer and "&amp;" not in answer

    def test_a_real_question_still_reaches_the_coach(self, services):
        s = self._services(services)
        answer = chat_turn(s, 1, "where can I cut back")
        assert answer  # falls through to the advisor

    def test_empty_message(self, services):
        assert chat_turn(self._services(services), 1, "   ") == ""

    def test_undo_works_from_chat(self, services):
        s = self._services(services)
        chat_turn(s, 1, "food 800")
        chat_turn(s, 1, "/undo")
        assert s.store.count(1) == 0


class TestConversation:
    """Plain text that is not an expense should be a conversation, with memory."""

    def _svc(self, services, answer="I hear you."):
        from finbot.features import default_features
        from finbot.registry import FeatureRegistry

        seen = {}

        def advise(**kw):
            seen["question"] = kw.get("question")
            seen["history"] = kw.get("history")
            return answer

        reg = FeatureRegistry()
        reg.register_all(default_features())
        services.registry = reg
        services.pending = {}
        services.conversations = {}
        # chat_json is the ingestor's no-amount fallback; returning a zero
        # amount makes it decline, which is what lets the message reach coaching.
        services.llm = SimpleNamespace(
            enabled=True,
            chat_json=lambda *a, **k: {"amount": 0},
            vision_json=lambda *a, **k: {"amount": 0},
        )
        services.ingestor = Ingestor(services.llm, "INR")
        services.advisor = SimpleNamespace(advise=advise)
        return services, seen

    def test_plain_question_is_answered_not_rejected(self, services):
        s, seen = self._svc(services)
        answer = chat_turn(s, 1, "what is my current status")
        assert answer == "I hear you."
        assert seen["question"] == "what is my current status"

    def test_expense_still_wins_over_conversation(self, services):
        s, seen = self._svc(services)
        chat_turn(s, 1, "food 800")
        assert s.store.count(1) == 1
        assert "question" not in seen  # coach never invoked

    def test_history_accumulates(self, services):
        s, _ = self._svc(services)
        chat_turn(s, 1, "hello")
        chat_turn(s, 1, "and then?")
        assert len(s.conversations[1]) == 4  # 2 user + 2 assistant

    def test_history_is_passed_to_the_advisor(self, services):
        s, seen = self._svc(services)
        chat_turn(s, 1, "hello")
        chat_turn(s, 1, "and then?")
        assert seen["history"][0]["content"] == "hello"

    def test_history_is_bounded(self, services):
        from finbot.features.coaching import MAX_HISTORY

        s, _ = self._svc(services)
        for i in range(20):
            chat_turn(s, 1, f"tell me more, round {chr(97 + i % 26)}")
        assert len(s.conversations[1]) == MAX_HISTORY

    def test_conversations_are_per_user(self, services):
        s, _ = self._svc(services)
        chat_turn(s, 1, "hello")
        chat_turn(s, 2, "hi")
        assert len(s.conversations[1]) == 2
        assert len(s.conversations[2]) == 2

    def test_forget_clears_only_the_conversation(self, services):
        s, _ = self._svc(services)
        chat_turn(s, 1, "coffee 250")
        chat_turn(s, 1, "hello")
        chat_turn(s, 1, "/forget")
        assert s.conversations.get(1) in (None, [])
        assert s.store.count(1) == 1  # expenses untouched