feat: 安全移除本地任务记录 (#108)
This commit is contained in:
+47
-4
@@ -6,7 +6,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
@@ -22,6 +22,7 @@ EXPECTED_INDEXES = {
|
||||
"idx_pdd_tasks_goods_id",
|
||||
"idx_task_runs_task",
|
||||
"idx_outbox_pending",
|
||||
"idx_pdd_tasks_visible_list",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +64,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -106,7 +107,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertIn("result_data", columns)
|
||||
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
@@ -169,7 +170,49 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row[0], "dry_run")
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3:
|
||||
connection.execute(statement)
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.execute(
|
||||
"INSERT INTO pdd_tasks"
|
||||
" (remote_task_id, task_type, goods_url, status, received_at,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"COL-V3",
|
||||
"collect",
|
||||
"https://example.test/goods",
|
||||
"succeeded",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
initialize_database(self.db_path)
|
||||
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(pdd_tasks)")
|
||||
}
|
||||
row = connection.execute(
|
||||
"SELECT remote_task_id, removed_at FROM pdd_tasks"
|
||||
" WHERE remote_task_id = 'COL-V3'"
|
||||
).fetchone()
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIn("removed_at", columns)
|
||||
self.assertEqual(tuple(row), ("COL-V3", None))
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
|
||||
@@ -63,9 +63,12 @@ class TaskTableModelCheckTests(unittest.TestCase):
|
||||
refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton))
|
||||
rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton))
|
||||
resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton))
|
||||
remove_position = layout.getItemPosition(layout.indexOf(page.removeButton))
|
||||
self.assertEqual(refresh_position[1], search_position[1] + 1)
|
||||
self.assertEqual(resubmit_position[1], rerun_position[1] + 1)
|
||||
self.assertEqual(remove_position[1], resubmit_position[1] + 1)
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 1 条")
|
||||
self.assertTrue(page.removeButton.isEnabled())
|
||||
|
||||
page.resize(1000, 650)
|
||||
self.app.processEvents()
|
||||
@@ -78,6 +81,26 @@ class TaskTableModelCheckTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(page.taskModel.checked_task_ids(), ())
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 0 条")
|
||||
self.assertFalse(page.removeButton.isEnabled())
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_emits_stable_checked_task_ids(self):
|
||||
page = PDDTaskPage()
|
||||
page.set_tasks(
|
||||
[TaskRow("TASK-1", "采集"), TaskRow("TASK-2", "采集")]
|
||||
)
|
||||
received = []
|
||||
page.removeRequested.connect(received.append)
|
||||
for row in (0, 1):
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(row, CHECK_COLUMN),
|
||||
Qt.Checked,
|
||||
Qt.CheckStateRole,
|
||||
)
|
||||
|
||||
page.removeButton.click()
|
||||
|
||||
self.assertEqual(received, [("TASK-1", "TASK-2")])
|
||||
page.deleteLater()
|
||||
|
||||
def test_auto_fetch_text_and_keyword_width(self):
|
||||
|
||||
@@ -627,6 +627,105 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_confirmation_cancel_keeps_local_task(self):
|
||||
self._add_task(1)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'succeeded'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
gateway = RecordingClaimGateway()
|
||||
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
|
||||
events.load_initial_tasks()
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
dialog = message_box.return_value
|
||||
dialog.exec.return_value = False
|
||||
page.removeButton.click()
|
||||
|
||||
message_box.assert_called_once()
|
||||
self.assertIs(message_box.call_args.args[2], page.window())
|
||||
dialog.cancelButton.setFocus.assert_called_once()
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
self.assertFalse(events._remove_busy)
|
||||
self.assertEqual(gateway.calls, [])
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_terminal_task_refreshes_list_and_keeps_audit_data(self):
|
||||
self._add_task(1)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'failed'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
gateway = RecordingClaimGateway()
|
||||
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
|
||||
events.load_initial_tasks()
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
message_box.return_value.exec.return_value = True
|
||||
page.removeButton.click()
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
|
||||
|
||||
self.assertEqual(page.taskModel.data_row_count(), 0)
|
||||
self.assertEqual(page.taskModel.checked_task_ids(), ())
|
||||
self.assertIsNotNone(self.repository.get_task("PDD-001"))
|
||||
self.assertIn("已从列表删除 1 条", page.statusLabel.text())
|
||||
self.assertEqual(gateway.calls, [])
|
||||
self.device_checker.assert_not_called()
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_mixed_batch_reports_reason_and_changes_nothing(self):
|
||||
self._add_task(1)
|
||||
self._add_task(2)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'cancelled'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(
|
||||
page, self.repository, claim_gateway=RecordingClaimGateway()
|
||||
)
|
||||
events.load_initial_tasks()
|
||||
for row in range(2):
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
message_box.return_value.exec.return_value = True
|
||||
page.removeButton.click()
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 2)
|
||||
self.assertEqual(len(page.taskModel.checked_task_ids()), 2)
|
||||
self.assertIn("尚未结束", page.statusLabel.text())
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_rerun_confirmation_has_clear_safe_cancel_action(self):
|
||||
self._add_task(1)
|
||||
first = self.repository.start_collect_run("PDD-001", "USB-001")
|
||||
|
||||
@@ -14,7 +14,12 @@ from src.task_models import (
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
)
|
||||
from src.task_repository import CollectRerunError, DuplicateTaskError, TaskRepository
|
||||
from src.task_repository import (
|
||||
CollectRerunError,
|
||||
DuplicateTaskError,
|
||||
TaskRemovalError,
|
||||
TaskRepository,
|
||||
)
|
||||
|
||||
|
||||
class TaskRepositoryTests(unittest.TestCase):
|
||||
@@ -26,6 +31,17 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def _set_task_status(self, task_id: str, status: TaskStatus) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = ? WHERE remote_task_id = ?",
|
||||
(status.value, task_id),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _task(
|
||||
remote_task_id: str,
|
||||
@@ -516,6 +532,108 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
OutboxStatus.PENDING,
|
||||
)
|
||||
|
||||
def test_terminal_task_is_soft_removed_but_audit_record_remains(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-OK"))
|
||||
self._set_task_status("REMOVE-OK", TaskStatus.SUCCEEDED)
|
||||
|
||||
removed_count = self.repository.remove_tasks_from_list(("REMOVE-OK",))
|
||||
|
||||
self.assertEqual(removed_count, 1)
|
||||
self.assertEqual(self.repository.list_tasks(), [])
|
||||
self.assertEqual(self.repository.count_tasks(), 0)
|
||||
self.assertIsNotNone(self.repository.get_task("REMOVE-OK"))
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT removed_at FROM pdd_tasks"
|
||||
" WHERE remote_task_id = 'REMOVE-OK'"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIsNotNone(row[0])
|
||||
with self.assertRaises(DuplicateTaskError):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-OK"))
|
||||
|
||||
def test_batch_remove_is_all_or_nothing_for_non_terminal_task(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-ENDED"))
|
||||
self.repository.add_claimed_task(self._task("REMOVE-RUNNING"))
|
||||
self._set_task_status("REMOVE-ENDED", TaskStatus.FAILED)
|
||||
self._set_task_status("REMOVE-RUNNING", TaskStatus.RUNNING)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "尚未结束"):
|
||||
self.repository.remove_tasks_from_list(
|
||||
("REMOVE-ENDED", "REMOVE-RUNNING")
|
||||
)
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 2)
|
||||
|
||||
def test_task_with_unsent_outbox_cannot_be_removed(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-UNSENT"))
|
||||
started = self.repository.start_collect_run("REMOVE-UNSENT", "USB-001")
|
||||
self.repository.save_collect_failure(
|
||||
"REMOVE-UNSENT",
|
||||
started.attempt_id,
|
||||
TaskStatus.FAILED,
|
||||
"TEST_FAILURE",
|
||||
"测试失败",
|
||||
False,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "未发送完成"):
|
||||
self.repository.remove_tasks_from_list(("REMOVE-UNSENT",))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
|
||||
def test_irreversible_task_cannot_be_removed_even_when_terminal(self):
|
||||
self.repository.add_claimed_task(
|
||||
self._task(
|
||||
"REMOVE-IRREVERSIBLE",
|
||||
TaskType.PURCHASE,
|
||||
execution_mode="live",
|
||||
)
|
||||
)
|
||||
started = self.repository.start_purchase_run(
|
||||
"REMOVE-IRREVERSIBLE", "USB-001"
|
||||
)
|
||||
self.repository.mark_purchase_irreversible(
|
||||
"REMOVE-IRREVERSIBLE", started.attempt_id
|
||||
)
|
||||
self._set_task_status("REMOVE-IRREVERSIBLE", TaskStatus.SUCCEEDED)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "不可逆阶段"):
|
||||
self.repository.remove_tasks_from_list(("REMOVE-IRREVERSIBLE",))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
|
||||
def test_sent_outbox_does_not_block_soft_remove(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-SENT"))
|
||||
started = self.repository.start_collect_run("REMOVE-SENT", "USB-001")
|
||||
event = self.repository.save_collect_result(
|
||||
"REMOVE-SENT",
|
||||
started.attempt_id,
|
||||
{"goods_id": "10001", "title": "测试商品", "skus": []},
|
||||
)
|
||||
self.repository.mark_outbox_sent(event.id)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.remove_tasks_from_list(("REMOVE-SENT",)), 1
|
||||
)
|
||||
self.assertEqual(self.repository.count_tasks(), 0)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
run_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM task_runs r JOIN pdd_tasks t"
|
||||
" ON t.id = r.task_id WHERE t.remote_task_id = 'REMOVE-SENT'"
|
||||
).fetchone()[0]
|
||||
outbox_row = connection.execute(
|
||||
"SELECT o.status FROM outbox_events o JOIN pdd_tasks t"
|
||||
" ON t.id = o.task_id WHERE t.remote_task_id = 'REMOVE-SENT'"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(run_count, 1)
|
||||
self.assertEqual(outbox_row[0], OutboxStatus.SENT.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user