feat(reading-goals): 持久化阅读目标并收紧看板布局

This commit is contained in:
admin committed 2026-09-12 17:16:40 +08:00
1 parent 61d3951905
commit 094e1aca3d
13 files changed
+834 -39

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function app() {
const nodes = new Map();
const node = selector => {
if (!nodes.has(selector)) nodes.set(selector, {
value: '', textContent: '', innerHTML: '', disabled: false,
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {}, addEventListener() {}, setCustomValidity() {}, reportValidity() {},
showModal() { this.open = true; }, close() { this.open = false; },
});
return nodes.get(selector);
};
const pending = () => new Promise(() => {});
const context = vm.createContext({
document: { querySelector: node, querySelectorAll: () => [], addEventListener() {}, documentElement: { dataset: {} } },
localStorage: { getItem: () => null },
WeApi: { status: pending, shelf: pending, dashboard: pending, goals: pending },
crypto: { randomUUID: () => 'test-goal' },
setTimeout() {}, clearTimeout() {}, console,
});
vm.runInContext(fs.readFileSync('static/js/app.js', 'utf8'), context);
return { context, node, run: code => vm.runInContext(code, context) };
}
test('目标书籍不在当前书架时仍保留总数,并可打开和编辑', () => {
const { run, node } = app();
run(`loadState.goals = 'ready';
books = [{id:'known',title:'已读书',status:'done'}];
goals = [{id:'g',name:'阅读',start:'2026-09-01',end:'2026-09-30',ids:['known','missing'],sources:[]}];
shelfData = {};`);
assert.equal(run('goalMetrics(goals[0]).total'), 2);
assert.equal(run('goalMetrics(goals[0]).percent'), 50);
run("openGoalDetail('g'); openGoalEditor('g');");
assert.match(node('#goal-picker').innerHTML, /状态待同步/);
assert.equal(run("selectedGoalBooks().includes('missing')"), true);
});
test('数据库保存失败时保留原目标和编辑草稿,允许重试', async () => {
const { context, run, node } = app();
run(`loadState.goals = 'ready';
shelfData = {}; books = [{id:'known',title:'一本书',status:'done'}];
goals = [{id:'g',name:'原名称',start:'2026-09-01',end:'2026-09-30',ids:['known'],sources:[]}];
openGoalEditor('g');`);
node('#goal-name').value = '新名称';
context.WeApi.saveGoal = async () => { throw new Error('数据库暂不可用'); };
await run('saveGoal({preventDefault(){}})');
assert.equal(run('goals[0].name'), '原名称');
assert.equal(node('#goal-editor').open, true);
assert.equal(node('#goal-name').value, '新名称');
assert.match(node('#goal-error').textContent, /数据库暂不可用/);
context.WeApi.saveGoal = async goal => ({ goal });
await run('saveGoal({preventDefault(){}})');
assert.equal(run('goals[0].name'), '新名称');
assert.equal(node('#goal-editor').open, false);
});
test('目标列表未完成加载时不打开编辑器', () => {
const { run, node } = app();
run(`page = 'overview';
loadState.goals = 'loading';
shelfData = {};
books = [{id:'known',title:'一本书',status:'done'}];
openGoalEditor(null, ['known'], []);`);
assert.notEqual(node('#goal-editor').open, true);
assert.equal(run('page'), 'goals');
assert.match(node('#page-content').innerHTML, /正在读取阅读目标/);
});
+164
View File
@@ -0,0 +1,164 @@
"""阅读目标接口回归测试;数据库故障通过连接边界模拟,不访问线上库。"""
import unittest
from datetime import date
from unittest.mock import MagicMock, patch
import pymysql
from flask import Flask
from api_v2 import v2_bp
class ReadingGoalsTest(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(v2_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("api_v2.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/v2/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_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/v2/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/v2/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/v2/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/v2/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/v2/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/v2/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/v2/goals"),
(self.client.put, "/api/v2/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/v2/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/v2/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/v2/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/v2/goals", None),
(self.client.post, "/api/v2/goals", self.goal),
(self.client.put, "/api/v2/goals/goal-1", self.goal),
(self.client.delete, "/api/v2/goals/goal-1", None),
(self.client.post, "/api/v2/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/v2/goals", json=self.goal)
self.assertEqual(response.status_code, 503)
self.conn.close.assert_called_once()
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
"""显式设置 WEREAD_TEST_MYSQL=1 后,验证已迁移数据库;仅清理本测试生成的 ID。"""
import os
import unittest
from uuid import uuid4
from unittest.mock import patch
import pymysql
from flask import Flask
import api_v2
@unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试')
class GoalsMySQLTest(unittest.TestCase):
def test_persistence_import_and_atomic_rollback(self):
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v2.v2_bp)
client = app.test_client()
ids = [f'test-{uuid4()}' for _ in range(4)]
goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01',
'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']}
try:
response = client.post('/api/v2/goals', json=goal)
self.assertEqual(response.status_code, 201, response.json)
self.assertEqual(len(response.json['goal']['ids']), 2)
# 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']}
self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'})
changed = dict(goal, name='修改后的测试', ids=['missing-book'])
self.assertEqual(client.put(f'/api/v2/goals/{ids[0]}', json=changed).status_code, 200)
imported = dict(goal, id=ids[1])
for count in (1, 0):
response = client.post('/api/v2/goals/import', json={'goals': [goal, imported, imported]})
self.assertEqual(response.json, {'imported': count})
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']}
self.assertEqual(saved[ids[0]]['name'], '修改后的测试')
self.assertEqual(saved[ids[0]]['ids'], ['missing-book'])
insert = api_v2.insert_goal
def fail_second(cur, item, skip_existing=False):
if item['id'] == ids[3]:
raise pymysql.OperationalError(2013, '模拟批次中断')
return insert(cur, item, skip_existing)
with patch('api_v2.insert_goal', side_effect=fail_second):
response = client.post('/api/v2/goals/import', json={
'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]})
self.assertEqual(response.status_code, 503)
saved = {g['id'] for g in client.get('/api/v2/goals').json['goals']}
self.assertNotIn(ids[2], saved)
self.assertNotIn(ids[3], saved)
self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200)
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],))
self.assertEqual(cur.fetchone()['n'], 0)
finally:
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids])
conn.commit()
if __name__ == '__main__':
unittest.main()