"""恢复路径测试:stale → reclaim → lease_count > 1 + known_state 覆盖规格 §5「绝不自动重投」与 §9 第 7 条「reclaim 领回的任务 lease_count > 1 且 带 known_state」。 """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from app.shared.config import get_settings from app.shared.task_state import OrderState, TaskStatus TOKEN = get_settings().bearer_token AUTH = {"Authorization": f"Bearer {TOKEN}"} @pytest.fixture def gateway_client(tmp_path: Path, monkeypatch): db_path = tmp_path / "gw.db" monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path)) # TTL 设小一点,让 lease 一过期就能被 sweep 转 stale monkeypatch.setenv("RAKUTEN_LEASE_TTL_SECONDS", "1") get_settings.cache_clear() try: from app.gateway.main import create_app app = create_app() with TestClient(app) as client: yield client finally: get_settings.cache_clear() def _submit_and_lease_and_report_progress(client: TestClient, task_id: str = "t1"): """提交 → lease → report 一次(known_state=in_cart),返回 lease 响应""" client.post( "/api/orders", json={"task_id": task_id, "site": "rakuten", "intent": {}}, headers=AUTH, ) lease = client.get(f"/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json() client.post( f"/api/orders/{task_id}/report", json={"worker_id": "w1", "state": OrderState.IN_CART.value, "detail": "已加购"}, headers=AUTH, ) return lease["data"] def test_stale_task_is_not_picked_by_normal_lease(gateway_client): """租约过期 → stale → 普通 lease 取不到""" _submit_and_lease_and_report_progress(gateway_client, "t1") # 等 TTL 过期(1 秒)+ sweep 触发:用 wait=0 的 lease 触发内嵌 sweep import time time.sleep(1.5) # 触发一次 lease(带另一个 worker_id)— 应当既取不到 t1(stale)也返回空 response = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH) assert response.status_code == 200 assert response.json()["data"] is None detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"] assert detail["status"] == TaskStatus.STALE.value def test_reclaim_returns_task_with_lease_count_above_one(gateway_client): """reclaim 领回 stale 任务,lease_count > 1,带 known_state""" _submit_and_lease_and_report_progress(gateway_client, "t1") import time time.sleep(1.5) # 触发一次 sweep gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH) assert ( gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]["status"] == TaskStatus.STALE.value ) response = gateway_client.post( "/api/orders/t1/reclaim", json={"worker_id": "w2"}, headers=AUTH ) assert response.status_code == 200 data = response.json()["data"] assert data["task_id"] == "t1" assert data["lease_count"] >= 2 # > 1 assert data["known_state"] == OrderState.IN_CART.value def test_reclaim_sets_leased_state_with_new_owner(gateway_client): """reclaim 后任务状态回到 leased,lease_owner 是新 worker""" _submit_and_lease_and_report_progress(gateway_client, "t1") import time time.sleep(1.5) gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH) gateway_client.post( "/api/orders/t1/reclaim", json={"worker_id": "w3"}, headers=AUTH ) detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"] assert detail["status"] == TaskStatus.LEASED.value assert detail["lease_owner"] == "w3" def test_double_reclaim_increments_lease_count(gateway_client): """连续 reclaim 多次,lease_count 持续 +1""" _submit_and_lease_and_report_progress(gateway_client, "t1") import time time.sleep(1.5) gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH) r1 = gateway_client.post( "/api/orders/t1/reclaim", json={"worker_id": "wA"}, headers=AUTH ).json()["data"] # wA 占着 lease,需要先释放(terminal report)才能再 reclaim gateway_client.post( "/api/orders/t1/report", json={ "worker_id": "wA", "state": OrderState.CREATED.value, "terminal": True, "terminal_status": TaskStatus.NEEDS_HUMAN.value, }, headers=AUTH, ) # 但 terminal 后任务不再 stale,无法再 reclaim。改用:让 lease 过期 # 这里直接验单次 reclaim 的语义即可,连续多次需要 stale→reclaim→过期→stale 循环 assert r1["lease_count"] == 2 def test_normal_lease_does_not_pick_stale_while_queued_exists(gateway_client): """stale + queued 并存时,普通 lease 只取 queued,绝不取 stale 顺序:提交 stale-task → lease → report 进度 → 提交 fresh-task → 等 lease 过期 → 用 w3 lease。lease 内部先 sweep(stale-task 转 stale),active 空,queued 有 fresh-task,应当只取到 fresh。 """ _submit_and_lease_and_report_progress(gateway_client, "stale-task") gateway_client.post( "/api/orders", json={"task_id": "fresh-task", "site": "rakuten", "intent": {}}, headers=AUTH, ) import time time.sleep(1.5) leased = gateway_client.get( "/api/orders/lease?worker_id=w3&wait=0", headers=AUTH ).json()["data"] assert leased is not None assert leased["task_id"] == "fresh-task" # stale-task 仍然停在 stale,没有被这次 lease 误取 detail = gateway_client.get("/api/orders/stale-task", headers=AUTH).json()["data"] assert detail["status"] == TaskStatus.STALE.value