Transfer bot was silently no-op'ing every Monday cycle since /user/
became paginated ({"rows": [...], "total": N}). The bot's silent
guard `len(items) if isinstance(items, list) else 0` collapsed every
contract mismatch and HTTP/JSON error into "0 items" with no signal.
- API: add /user/batch — bare-list, no pagination — for batch jobs.
Keeps the paginated /user/ contract intact for the web UI.
- Bot: replace silent guard with raise_for_status + isinstance check.
On any HTTP/JSON/contract failure, log + Telegram-alert + skip the
cycle (next attempt in 10 min). Empty list still means "no work".
- Tests: 15 new tests pinning both sides of the contract, including a
regression test that feeds the exact envelope shape that broke prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
6.2 KiB
Python
155 lines
6.2 KiB
Python
"""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/<username> (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()
|