feat: 领取 Admin 采集任务并保存本地 (#30)

This commit is contained in:
chengma
2026-08-07 16:42:31 +08:00
parent 107366f1f0
commit 51062f4c7b
7 changed files with 841 additions and 15 deletions
+103
View File
@@ -116,6 +116,109 @@ class HttpAdminGatewayTest(unittest.TestCase):
key.lower(): value for key, value in opener.request.header_items()
})
def test_claim_maps_real_admin_payload_and_only_reports_collect(self):
opener = RecordingOpener(
FakeResponse(
200,
{
"task": {
"id": "COL-8020a8729f111c15",
"type": "collect",
"version": 1,
"priority": 0,
"payload": {
"goods_id": "737116531267",
"goods_url": (
"https://mobile.yangkeduo.com/goods.html"
"?goods_id=737116531267"
),
},
"created_at": "2026-08-07T03:19:49Z",
"updated_at": "2026-08-07T03:19:49Z",
}
},
)
)
gateway = HttpAdminGateway(opener=opener)
task = gateway.claim_next(
ClientInfo("CLIENT-001", "办公室电脑"),
self._capabilities(),
)
self.assertIsNotNone(task)
self.assertEqual(task.task_id, "COL-8020a8729f111c15")
self.assertEqual(task.task_type.value, "collect")
self.assertEqual(task.payload["goods_id"], "737116531267")
self.assertEqual(opener.request.get_method(), "POST")
self.assertEqual(
opener.request.full_url,
"http://127.0.0.1:8080/api/v1/client/tasks/claim",
)
headers = {
key.lower(): value for key, value in opener.request.header_items()
}
self.assertEqual(headers["x-client-id"], "CLIENT-001")
self.assertTrue(headers["x-request-id"])
self.assertIn("application/json", headers["content-type"])
body = json.loads(opener.request.data.decode("utf-8"))
self.assertEqual(body["supported_types"], ["collect"])
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
def test_claim_204_returns_none(self):
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
task = gateway.claim_next(
ClientInfo("CLIENT-001"),
self._capabilities(),
)
self.assertIsNone(task)
def test_claim_invalid_task_fields_include_request_id(self):
gateway = HttpAdminGateway(
opener=RecordingOpener(FakeResponse(200, {"task": {"id": None}}))
)
with self.assertRaises(AdminGatewayError) as context:
gateway.claim_next(
ClientInfo("CLIENT-001"),
self._capabilities(),
)
self.assertEqual(context.exception.code, "ADMIN_INVALID_RESPONSE")
self.assertTrue(context.exception.request_id)
def test_claim_http_error_keeps_server_request_id(self):
error_body = json.dumps(
{
"error": {
"code": "TASK_CLAIM_FAILED",
"message": "领取任务失败",
"retryable": True,
"request_id": "claim-request-id",
}
}
).encode("utf-8")
error = HTTPError(
"http://admin/api/v1/client/tasks/claim",
500,
"Internal Server Error",
{},
io.BytesIO(error_body),
)
gateway = HttpAdminGateway(opener=RecordingOpener(error))
with self.assertRaises(AdminGatewayError) as context:
gateway.claim_next(
ClientInfo("CLIENT-001"),
self._capabilities(),
)
self.assertEqual(context.exception.code, "TASK_CLAIM_FAILED")
self.assertEqual(context.exception.request_id, "claim-request-id")
self.assertTrue(context.exception.retryable)
def test_admin_error_preserves_code_retry_and_request_id(self):
error_body = json.dumps(
{
+241 -1
View File
@@ -2,6 +2,8 @@
import os
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -10,7 +12,12 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtWidgets import QApplication
from src.pdd_ui import PDDTaskPage
from src.pdd_ui_event import PDDTaskPageEvent, summary_to_row
from src.admin_gateway import AdminTask
from src.pdd_ui_event import (
PDDTaskPageEvent,
admin_task_to_new_claimed_task,
summary_to_row,
)
from src.mock_admin_gateway import MockAdminGateway
from src.settings_repository import SettingsRepository
from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
@@ -25,6 +32,70 @@ class BrokenRepository:
raise RuntimeError("database is unavailable")
class BrokenSaveRepository(BrokenRepository):
"""模拟任务已经在 Admin 领取,但本地写入失败。"""
def add_claimed_task(self, _task):
raise RuntimeError("disk is full")
class RecordingClaimGateway:
"""记录领取参数并返回预设结果。"""
def __init__(self, response=None):
self.response = response
self.calls = []
self.thread_ids = []
def claim_next(self, client, capabilities):
self.calls.append((client, capabilities))
self.thread_ids.append(threading.get_ident())
return self.response
class SlowClaimGateway(RecordingClaimGateway):
"""让关闭测试能稳定发生在 HTTP 返回之前。"""
def __init__(self, response):
super().__init__(response)
self.started = threading.Event()
def claim_next(self, client, capabilities):
self.started.set()
time.sleep(0.1)
return super().claim_next(client, capabilities)
def collect_admin_task(task_id="COL-001"):
return AdminTask(
task_id=task_id,
task_type=TaskType.COLLECT,
version=1,
priority=0,
payload={
"goods_id": "737116531267",
"goods_url": (
"https://mobile.yangkeduo.com/goods.html"
"?goods_id=737116531267"
),
},
created_at="2026-08-07T03:19:49Z",
updated_at="2026-08-07T03:19:49Z",
)
def wait_until(application, predicate, timeout=3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
application.processEvents()
if predicate():
application.processEvents()
return True
time.sleep(0.01)
application.processEvents()
return predicate()
class PDDTaskPageEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -59,6 +130,17 @@ class PDDTaskPageEventTest(unittest.TestCase):
received_at="2026-08-06T08:00:00Z",
)
def _saved_settings(self):
settings = SettingsRepository(self.db_path)
settings.set_many(
{
"admin.client_id": "CLIENT-001",
"admin.client_name": "办公室电脑",
"android.selected_serial": "USB-001",
}
)
return settings
def test_initial_load_and_fetch_next_page(self):
for number in range(51):
self._add_task(number)
@@ -143,6 +225,164 @@ class PDDTaskPageEventTest(unittest.TestCase):
window.close()
window.deleteLater()
def test_admin_task_mapping_uses_explicit_real_field_names(self):
local_task = admin_task_to_new_claimed_task(
collect_admin_task("COL-8020a8729f111c15")
)
self.assertEqual(local_task.remote_task_id, "COL-8020a8729f111c15")
self.assertEqual(local_task.task_type, TaskType.COLLECT)
self.assertEqual(local_task.goods_id, "737116531267")
self.assertEqual(
local_task.admin_payload["payload"]["goods_id"],
"737116531267",
)
def test_click_claims_one_collect_task_saves_and_refreshes_table(self):
gateway = RecordingClaimGateway(collect_admin_task())
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
self.assertTrue(
wait_until(self.app, lambda: not events._claim_busy)
)
self.assertEqual(len(gateway.calls), 1)
self.assertNotEqual(gateway.thread_ids[0], threading.get_ident())
_, capabilities = gateway.calls[0]
self.assertEqual(
[task_type.value for task_type in capabilities.supported_types],
["collect"],
)
self.assertEqual(self.repository.count_tasks(), 1)
self.assertEqual(page.taskModel.data_row_count(), 1)
self.assertEqual(page.taskModel.row_at(0).remote_task_id, "COL-001")
self.assertEqual(page.autoFetchButton.text(), "获取任务")
self.assertIn("COL-001", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_204_is_neutral_and_each_click_only_calls_once(self):
gateway = RecordingClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(len(gateway.calls), 1)
self.assertEqual(page.statusLabel.text(), "暂无可领取的采集任务")
self.assertEqual(self.repository.count_tasks(), 0)
events.shutdown()
page.deleteLater()
def test_repeated_click_while_claiming_does_not_start_second_request(self):
gateway = SlowClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
page.autoFetchRequested.emit()
self.assertFalse(page.autoFetchButton.isEnabled())
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(len(gateway.calls), 1)
events.shutdown()
page.deleteLater()
def test_duplicate_task_is_normal_status(self):
task = collect_admin_task()
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
gateway = RecordingClaimGateway(task)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIn("本地已有", page.statusLabel.text())
self.assertEqual(self.repository.count_tasks(), 1)
events.shutdown()
page.deleteLater()
def test_local_save_failure_status_contains_claimed_task_id(self):
gateway = RecordingClaimGateway(collect_admin_task("COL-LOST"))
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
BrokenSaveRepository(),
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIn("COL-LOST", page.statusLabel.text())
self.assertIn("本地保存失败", page.statusLabel.text())
self.assertIn("disk is full", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_missing_client_settings_do_not_call_admin(self):
gateway = RecordingClaimGateway(collect_admin_task())
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=SettingsRepository(self.db_path),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(gateway.calls, [])
self.assertIn("设置页", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_shutdown_after_claim_started_still_saves_task_without_ui_callback(self):
gateway = SlowClaimGateway(collect_admin_task("COL-CLOSE"))
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, gateway.started.is_set))
status_before_close = page.statusLabel.text()
events.shutdown()
self.app.processEvents()
self.assertIsNotNone(self.repository.get_task("COL-CLOSE"))
self.assertEqual(page.statusLabel.text(), status_before_close)
page.deleteLater()
if __name__ == "__main__":
unittest.main()