# Testing Guide ## Overview The project uses Python's built-in `unittest` framework with `unittest.mock` for mocking external dependencies (Google Sheets API, attendance data). Tests live in the `tests/` directory. ## Running Tests ```bash make test # Run all tests make test-v # Run with verbose output ``` Under the hood: ```bash PYTHONPATH=scripts:. python3 -m unittest discover tests ``` The `PYTHONPATH` includes both `scripts/` and the project root so that test files can import from both `app.py` and `scripts/*.py`. ## Test Files ### `test_app.py` — Flask Route Tests Tests the Flask web application routes using Flask's built-in test client. All external data fetching is mocked. | Test | What it verifies | |------|-----------------| | `test_index_page` | `GET /` returns 200 and contains a redirect to `/fees` | | `test_fees_route` | `GET /fees` renders the fees dashboard with correct member names | | `test_reconcile_route` | `GET /reconcile` renders the reconciliation page with payment matching | | `test_payments_route` | `GET /payments` renders the ledger with grouped transactions | **Mocking strategy**: ```python @patch('app.get_members_with_fees') def test_fees_route(self, mock_get_members): mock_get_members.return_value = ( [('Test Member', 'A', {'2026-01': (750, 4)})], ['2026-01'] ) response = self.client.get('/fees') self.assertEqual(response.status_code, 200) self.assertIn(b'Test Member', response.data) ``` Each test patches the data-fetching functions (`get_members_with_fees`, `fetch_sheet_data`) to return controlled test data, avoiding any network calls. **Notable**: The reconcile route test also mocks `fetch_sheet_data` and verifies that the reconciliation engine correctly matches a payment against an expected fee (checking for "OK" in the response). ### `test_reconcile_exceptions.py` — Reconciliation Logic Tests Tests the `reconcile()` function directly (unit tests for the core business logic): | Test | What it verifies | |------|-----------------| | `test_reconcile_applies_exceptions` | When a fee exception exists (400 CZK instead of 750), the expected amount is overridden and balance is calculated correctly | | `test_reconcile_fallback_to_attendance` | When no exception exists, the attendance-based fee is used | **Why these tests matter**: The exception system is critical for correctness — an incorrect override could cause members to be shown incorrect amounts owed. These tests verify that: - Exceptions properly override the attendance-based fee - The absence of an exception correctly falls back to the standard calculation - Balances are computed correctly against overridden amounts ## Test Data Patterns The tests use minimal but representative data: ```python # A member with attendance-based fee members = [('Alice', 'A', {'2026-01': (750, 4)})] # An exception reducing the fee exceptions = {('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}} # A matching payment transactions = [{ 'date': '2026-01-05', 'amount': 400, 'person': 'Alice', 'purpose': '2026-01', 'inferred_amount': 400, 'sender': 'Alice Sender', 'message': 'fee' }] ``` ## What's Not Tested | Area | Status | Notes | |------|--------|-------| | Name matching logic | ❌ Not tested | `match_members()`, `_build_name_variants()` | | Czech month parsing | ❌ Not tested | `parse_month_references()` | | Fio bank data fetching | ❌ Not tested | Both API and HTML scraping | | Sync deduplication | ❌ Not tested | `generate_sync_id()` | | QR code generation | ❌ Not tested | `/qr` route | | Payment inference | ❌ Not tested | `infer_payments.py` logic | | Multi-person payment splitting | ❌ Not tested | Even split across members/months | | Edge cases | ❌ Not tested | Empty sheets, malformed dates, etc. | ## Writing New Tests ### Adding a Flask route test ```python # In tests/test_app.py @patch('app.some_function') def test_new_route(self, mock_fn): mock_fn.return_value = expected_data response = self.client.get('/new-route') self.assertEqual(response.status_code, 200) self.assertIn(b'expected content', response.data) ``` ### Adding a reconciliation logic test ```python # In tests/test_reconcile_exceptions.py (or a new test file) def test_multi_month_payment(self): members = [('Bob', 'A', { '2026-01': (750, 3), '2026-02': (750, 4) })] transactions = [{ 'date': '2026-02-01', 'amount': 1500, 'person': 'Bob', 'purpose': '2026-01, 2026-02', 'inferred_amount': 1500, 'sender': 'Bob', 'message': 'leden+unor' }] result = reconcile(members, ['2026-01', '2026-02'], transactions) bob = result['members']['Bob'] self.assertEqual(bob['months']['2026-01']['paid'], 750) self.assertEqual(bob['months']['2026-02']['paid'], 750) self.assertEqual(bob['total_balance'], 0) ``` --- *Testing documentation generated from comprehensive code analysis on 2026-03-03.*