"""Tests for the /user/batch endpoint in app.cm_api. This is the API side of the contract that fetch_users() consumes (see test_transfer_fetch_users.py for the bot side). The two MUST stay in lockstep: bot expects bare list, API must serve bare list. The previous production bug happened because /user/ was silently changed to return {"rows": [...], "total": N}; the bot's silent guard collapsed that to "0 items". /user/batch is a dedicated batch endpoint that locks the bare-list contract for back-end batch jobs, separate from the paginated UI endpoint. Tests use a small fixture (3 rows) — production has thousands but the contract is identical regardless of cardinality. """ import unittest from unittest import mock # Three rows is enough to exercise the contract — production has many # more, but cardinality is irrelevant to what these tests verify. SAMPLE_ROWS = [ {"f_username": "13c1", "f_password": "p1", "t_username": "tA", "t_password": "pA", "last_update_time": "Mon, 04 May 2026 06:00:00 GMT"}, {"f_username": "13c2", "f_password": "p2", "t_username": "tB", "t_password": "pB", "last_update_time": "Sun, 03 May 2026 06:00:00 GMT"}, {"f_username": "13c3", "f_password": "p3", "t_username": "tC", "t_password": "pC", "last_update_time": "Sat, 02 May 2026 06:00:00 GMT"}, ] EXPECTED_USER_FIELDS = {"f_username", "f_password", "t_username", "t_password", "last_update_time"} def _make_client(query_result=None, query_side_effect=None, db_constructor_raises=None): """Build a Flask test client with the DB layer mocked. Patches `app.cm_api.DB` (the imported symbol) and `app.cm_api.verify_tables_once` (called by the before_request hook on the first request). Returns (client, mock_db_instance) so tests can assert on what queries were issued. """ db_patcher = mock.patch("app.cm_api.DB") verify_patcher = mock.patch("app.cm_api.verify_tables_once") mock_db_class = db_patcher.start() verify_patcher.start() if db_constructor_raises is not None: mock_db_class.side_effect = db_constructor_raises mock_db_instance = None else: mock_db_instance = mock.Mock() if query_side_effect is not None: mock_db_instance.query.side_effect = query_side_effect else: mock_db_instance.query.return_value = query_result if query_result is not None else [] mock_db_class.return_value = mock_db_instance from app.cm_api import create_app app = create_app() app.testing = True client = app.test_client() return client, mock_db_instance, (db_patcher, verify_patcher) class UserBatchEndpointTests(unittest.TestCase): def tearDown(self): # mock.patch.stopall() also unwinds patches started outside # context managers above; safer than tracking each patcher. mock.patch.stopall() def test_returns_bare_list_not_envelope(self): # THE contract test. The paginated /user/ returns # {"rows": [...], "total": N} — that shape is what broke the # transfer bot. /user/batch must return a bare list so the bot # can len() it directly. client, _, _ = _make_client(query_result=SAMPLE_ROWS) response = client.get("/user/batch") self.assertEqual(response.status_code, 200) body = response.get_json() self.assertIsInstance( body, list, f"contract violation: expected list, got {type(body).__name__}: {body!r}", ) self.assertNotIsInstance(body, dict, "must NOT wrap in {'rows': ...}") def test_returns_all_rows_with_expected_schema(self): client, _, _ = _make_client(query_result=SAMPLE_ROWS) response = client.get("/user/batch") body = response.get_json() self.assertEqual(len(body), 3) for row in body: self.assertEqual(set(row.keys()), EXPECTED_USER_FIELDS) def test_empty_table_returns_empty_list_not_error(self): # Empty user table is "no work to do", not a failure. Must be # 200 + [] so the bot's empty-list branch fires (NOT the # error-skip branch). client, _, _ = _make_client(query_result=[]) response = client.get("/user/batch") self.assertEqual(response.status_code, 200) self.assertEqual(response.get_json(), []) def test_query_has_no_pagination_clauses(self): # Defensive: if someone "helpfully" adds LIMIT/OFFSET to the # batch query later, the bot would silently process only a # subset and the rest would be missed for a week. Pin that # the SELECT against the user table is unbounded. client, mock_db, _ = _make_client(query_result=SAMPLE_ROWS) client.get("/user/batch") sql = mock_db.query.call_args.args[0] self.assertIn("FROM user", sql) self.assertNotIn("LIMIT", sql.upper()) self.assertNotIn("OFFSET", sql.upper()) def test_db_unavailable_returns_500(self): # DB() constructor raising should not 200 with empty list — must # surface as an error so the bot's error branch fires + alerts. client, _, _ = _make_client(db_constructor_raises=Exception("conn refused")) response = client.get("/user/batch") self.assertEqual(response.status_code, 500) def test_query_exception_returns_500(self): client, _, _ = _make_client(query_side_effect=Exception("query failed")) response = client.get("/user/batch") self.assertEqual(response.status_code, 500) def test_route_resolves_to_batch_handler_not_username_lookup(self): # Werkzeug should pick /user/batch (static segment) over # /user/ (variable segment). If route order/specificity # ever regresses, the bot would silently get back an empty list # from a username lookup for the literal string "batch". with mock.patch("app.cm_api.DB"), mock.patch("app.cm_api.verify_tables_once"): from app.cm_api import create_app app = create_app() urls = app.url_map.bind("localhost") endpoint, _ = urls.match("/user/batch", method="GET") self.assertEqual(endpoint, "get_user_batch") if __name__ == "__main__": unittest.main()