mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
170 lines
8.1 KiB
Python
170 lines
8.1 KiB
Python
"""阅读目标接口回归测试;数据库故障通过连接边界模拟,不访问线上库。"""
|
|
|
|
import unittest
|
|
from datetime import date
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pymysql
|
|
from flask import Flask
|
|
|
|
from reading_api import reading_bp
|
|
|
|
|
|
class ReadingGoalsTest(unittest.TestCase):
|
|
def setUp(self):
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(reading_bp)
|
|
self.client = app.test_client()
|
|
self.conn = MagicMock()
|
|
self.cur = self.conn.cursor.return_value.__enter__.return_value
|
|
self.cur.fetchone.return_value = {"id": "goal-1"}
|
|
self.cur.fetchall.return_value = []
|
|
self.cur.rowcount = 1
|
|
self.connect = patch("reading_api.pymysql.connect", return_value=self.conn).start()
|
|
self.addCleanup(patch.stopall)
|
|
self.goal = {
|
|
"id": "goal-1", "name": " 九月阅读 ",
|
|
"start": "2026-09-01", "end": "2026-09-30",
|
|
"ids": ["unknown-book", "unknown-book", "123"],
|
|
"sources": ["科幻书单", "科幻书单"],
|
|
}
|
|
|
|
def test_create_normalizes_and_commits_unknown_members(self):
|
|
response = self.client.post("/api/reading/goals", json=self.goal)
|
|
self.assertEqual(response.status_code, 201)
|
|
expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"])
|
|
self.assertEqual(response.json, {"goal": expected})
|
|
self.conn.commit.assert_called_once()
|
|
self.conn.rollback.assert_not_called()
|
|
self.conn.close.assert_called_once()
|
|
|
|
def test_legacy_goal_path_remains_available(self):
|
|
response = self.client.post("/api/" + "v" + "2" + "/goals", json=self.goal)
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertEqual(response.json["goal"]["id"], "goal-1")
|
|
|
|
def test_rejects_invalid_fields_without_writes(self):
|
|
invalid = [
|
|
{"id": 123}, {"id": ""}, {"id": "x" * 65},
|
|
{"name": " "}, {"name": "字" * 61},
|
|
{"start": "2026-02-29"}, {"start": "2026-9-01"},
|
|
{"start": "2026-09-31"}, {"end": "2026-08-31"},
|
|
{"ids": []}, {"ids": "123"}, {"ids": [123]},
|
|
{"ids": [""]}, {"ids": ["x" * 65]},
|
|
{"sources": "书单"}, {"sources": [123]},
|
|
]
|
|
for fields in invalid:
|
|
with self.subTest(fields=fields):
|
|
response = self.client.post("/api/reading/goals", json=dict(self.goal, **fields))
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertTrue(response.json["error"])
|
|
self.cur.execute.assert_not_called()
|
|
self.conn.commit.assert_not_called()
|
|
|
|
def test_invalid_json_and_body_shape_are_chinese_errors(self):
|
|
for body in ("{", "null", "[]"):
|
|
response = self.client.post("/api/reading/goals", data=body, content_type="application/json")
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"]))
|
|
|
|
def test_list_preserves_unknown_members_and_formats_dates(self):
|
|
self.cur.fetchall.return_value = [
|
|
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
|
|
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "unknown-book"},
|
|
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
|
|
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"},
|
|
]
|
|
response = self.client.get("/api/reading/goals")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json, {"goals": [dict(
|
|
self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]
|
|
)]})
|
|
|
|
def test_create_duplicate_returns_conflict(self):
|
|
self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry")
|
|
response = self.client.post("/api/reading/goals", json=self.goal)
|
|
self.assertEqual(response.status_code, 409)
|
|
self.conn.commit.assert_not_called()
|
|
self.conn.rollback.assert_called_once()
|
|
|
|
def test_update_and_delete_missing_goal_return_not_found(self):
|
|
self.cur.fetchone.return_value = None
|
|
self.cur.rowcount = 0
|
|
for method in (self.client.put, self.client.delete):
|
|
response = method("/api/reading/goals/missing", json=dict(self.goal, id="missing"))
|
|
self.assertEqual(response.status_code, 404)
|
|
self.conn.commit.assert_not_called()
|
|
|
|
def test_update_rejects_mismatched_id(self):
|
|
response = self.client.put("/api/reading/goals/other", json=self.goal)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.cur.execute.assert_not_called()
|
|
|
|
def test_member_write_failure_rolls_back_parent_change(self):
|
|
self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
|
|
for method, path in ((self.client.post, "/api/reading/goals"),
|
|
(self.client.put, "/api/reading/goals/goal-1")):
|
|
with self.subTest(path=path):
|
|
self.conn.reset_mock()
|
|
response = method(path, json=self.goal)
|
|
self.assertEqual(response.status_code, 503)
|
|
self.conn.rollback.assert_called_once()
|
|
self.conn.commit.assert_not_called()
|
|
|
|
def test_import_validates_entire_batch_before_writes(self):
|
|
response = self.client.post("/api/reading/goals/import", json={
|
|
"goals": [self.goal, dict(self.goal, id="bad", ids=[])]
|
|
})
|
|
self.assertEqual(response.status_code, 400)
|
|
self.cur.execute.assert_not_called()
|
|
|
|
def test_import_skips_existing_and_duplicate_batch_ids(self):
|
|
# 第一个 ID 已存在;第二个 ID 首次插入;批内重复项不再写入。
|
|
counts = iter([0, 1])
|
|
def execute(sql, params=None):
|
|
self.cur.rowcount = next(counts)
|
|
return self.cur.rowcount
|
|
self.cur.execute.side_effect = execute
|
|
response = self.client.post("/api/reading/goals/import", json={"goals": [
|
|
self.goal, dict(self.goal, id="new"), dict(self.goal, id="new")
|
|
]})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json, {"imported": 1})
|
|
self.cur.executemany.assert_called_once()
|
|
self.conn.commit.assert_called_once()
|
|
|
|
def test_import_second_member_failure_rolls_back_whole_batch(self):
|
|
self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")]
|
|
response = self.client.post("/api/reading/goals/import", json={
|
|
"goals": [self.goal, dict(self.goal, id="second")]
|
|
})
|
|
self.assertEqual(response.status_code, 503)
|
|
self.conn.rollback.assert_called_once()
|
|
self.conn.commit.assert_not_called()
|
|
|
|
def test_database_failures_never_return_successful_empty_data(self):
|
|
self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable")
|
|
for method, path, body in (
|
|
(self.client.get, "/api/reading/goals", None),
|
|
(self.client.post, "/api/reading/goals", self.goal),
|
|
(self.client.put, "/api/reading/goals/goal-1", self.goal),
|
|
(self.client.delete, "/api/reading/goals/goal-1", None),
|
|
(self.client.post, "/api/reading/goals/import", {"goals": [self.goal]}),
|
|
):
|
|
with self.subTest(path=path, method=method):
|
|
response = method(path, json=body)
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertIn("数据库", response.json["error"])
|
|
|
|
def test_commit_and_rollback_connection_failures_still_return_503(self):
|
|
self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
|
|
self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed")
|
|
response = self.client.post("/api/reading/goals", json=self.goal)
|
|
self.assertEqual(response.status_code, 503)
|
|
self.conn.close.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|