import unittest from unittest.mock import MagicMock, patch from scripts.match_payments import ( canonical_member_key, fetch_sheet_data, infer_transaction_details, is_other_purpose, reconcile, ) class TestCanonicalMemberKey(unittest.TestCase): def test_diacritics_and_case_collapse(self): self.assertEqual(canonical_member_key("Mária Maco"), "maria maco") self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco") self.assertEqual(canonical_member_key("maria maco"), "maria maco") def test_whitespace_runs_collapse(self): self.assertEqual(canonical_member_key("Mária Maco"), "maria maco") self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco") def test_unknown_name_passes_through_normalized(self): # Two genuinely different names must not collide. self.assertNotEqual( canonical_member_key("Mária Maco"), canonical_member_key("Marek Maco"), ) class TestReconcileTolerantPersonMatching(unittest.TestCase): def _members(self): return [("Mária Maco", "A", {"2026-04": (750, 4)})] def _tx(self, person): return { "date": "2026-04-15", "amount": 750, "person": person, "purpose": "2026-04", "inferred_amount": 750, "sender": "Maco Family", "message": "fee", } def test_person_without_diacritics_matches(self): result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {}) member = result["members"]["Mária Maco"] self.assertEqual(member["months"]["2026-04"]["paid"], 750) self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1) self.assertEqual(result["unmatched"], []) def test_person_with_extra_whitespace_matches(self): result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {}) self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750) self.assertEqual(result["unmatched"], []) def test_person_lowercase_matches(self): result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {}) self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750) self.assertEqual(result["unmatched"], []) def test_truly_unknown_person_still_unmatched(self): result = reconcile( self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {} ) self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0) self.assertEqual(len(result["unmatched"]), 1) if __name__ == "__main__": unittest.main() class TestFetchSheetDataVS(unittest.TestCase): """Numeric VS cells arrive as floats (UNFORMATTED_VALUE) and must stay plain digit strings.""" def _fetch(self, vs_cell): service = MagicMock() service.spreadsheets().values().get().execute.return_value = {"values": [ ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"], ["2026-04-15", 750, "", "", "", "", "Test Sender", vs_cell, "fee", "", "abc"], ]} with patch("scripts.match_payments.get_sheets_service", return_value=service): return fetch_sheet_data("sheet-id", "creds.json") def test_numeric_vs_is_plain_digit_string(self): self.assertEqual(self._fetch(2609767878.0)[0]["vs"], "2609767878") def test_string_vs_is_kept(self): self.assertEqual(self._fetch("0308")[0]["vs"], "0308") class TestOtherPurposeInference(unittest.TestCase): """A message starting with "other:" is not a monthly fee.""" MEMBERS = ["Jana Nováková", "Adam Novák"] def test_is_other_purpose(self): for text in ("other: tournament", "other:shirt", " Other: Tournament", "OTHER:x"): self.assertTrue(is_other_purpose(text), text) for text in ("leden other: x", "other tournament", "another: x", "2026-01", "", None): self.assertFalse(is_other_purpose(text), text) def test_message_becomes_purpose_and_no_month_is_inferred(self): tx = {"sender": "Jana Novakova", "message": " other: turnaj leden 2026 ", "date": "2026-04-15"} result = infer_transaction_details(tx, self.MEMBERS) self.assertEqual(result["members"], [("Jana Nováková", "auto")]) self.assertEqual(result["months"], ["other: turnaj leden 2026"]) def test_unknown_member_still_yields_purpose(self): tx = {"sender": "Unknown Payer", "message": "Other: Dres", "date": "2026-04-15"} result = infer_transaction_details(tx, self.MEMBERS) self.assertEqual(result["members"], []) self.assertEqual(result["months"], ["Other: Dres"]) def test_other_not_at_start_is_handled_the_old_way(self): tx = {"sender": "Jana Novakova", "message": "leden other: dres", "date": "2026-04-15"} result = infer_transaction_details(tx, self.MEMBERS) self.assertEqual(result["months"], ["2026-01"]) def test_reconcile_fallback_puts_it_in_other_ledger(self): members = [("Jana Nováková", "A", {"2026-01": (750, 3)})] tx = {"date": "2026-01-10", "amount": 300, "person": "", "purpose": "", "sender": "Jana Novakova", "message": "other: dres", "inferred_amount": ""} result = reconcile(members, ["2026-01"], [tx], {}) member = result["members"]["Jana Nováková"] self.assertEqual(member["months"]["2026-01"]["paid"], 0) self.assertEqual(len(member["other_transactions"]), 1) self.assertEqual(member["other_transactions"][0]["amount"], 300) self.assertEqual(member["other_transactions"][0]["purpose"], "other: dres") self.assertEqual(result["unmatched"], [])