1157 lines
39 KiB
Python
1157 lines
39 KiB
Python
# -*- encoding=utf8 -*-
|
||
"""
|
||
Block 测试任务功能单元测试
|
||
|
||
覆盖:
|
||
1. Block 任务加载(task_key / task_details / 高优先队列)
|
||
2. Worker 收到的 payload 包含 blocked 参数
|
||
3. Block 任务重试逻辑(下载错误重试,其他错误直接失败)
|
||
4. Analytics snapshot 跳过 block 任务
|
||
5. Block 测试报告生成
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
import os
|
||
import tempfile
|
||
import csv
|
||
|
||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
import pytest
|
||
|
||
from analytics import _make_task_key
|
||
|
||
|
||
class FakeRedis:
|
||
"""模拟 Redis 操作"""
|
||
|
||
def __init__(self):
|
||
self.lists: dict = {}
|
||
self.hashes: dict = {}
|
||
self.sets: dict = {}
|
||
self.published: list = []
|
||
|
||
def delete(self, key):
|
||
self.lists.pop(key, None)
|
||
self.hashes.pop(key, None)
|
||
self.sets.pop(key, None)
|
||
|
||
def lpush(self, key, value):
|
||
self.lists.setdefault(key, []).insert(0, value)
|
||
|
||
def rpush(self, key, value):
|
||
self.lists.setdefault(key, []).append(value)
|
||
|
||
def rpop(self, key):
|
||
values = self.lists.setdefault(key, [])
|
||
return values.pop() if values else None
|
||
|
||
def lrange(self, key, start, end):
|
||
values = self.lists.get(key, [])
|
||
stop = None if end == -1 else end + 1
|
||
return values[start:stop]
|
||
|
||
def llen(self, key):
|
||
return len(self.lists.get(key, []))
|
||
|
||
def lpos(self, key, value):
|
||
try:
|
||
return self.lists.get(key, []).index(value)
|
||
except ValueError:
|
||
return None
|
||
|
||
def lrem(self, key, count, value):
|
||
values = self.lists.get(key, [])
|
||
original_len = len(values)
|
||
self.lists[key] = [item for item in values if item != value]
|
||
return original_len - len(self.lists[key])
|
||
|
||
def hset(self, key, *args, **kwargs):
|
||
if len(args) == 2:
|
||
field, value = args
|
||
self.hashes.setdefault(key, {})[field] = value
|
||
elif len(args) == 1 and isinstance(args[0], dict):
|
||
self.hashes.setdefault(key, {}).update(args[0])
|
||
|
||
def hget(self, key, field):
|
||
return self.hashes.get(key, {}).get(field)
|
||
|
||
def hgetall(self, key):
|
||
return dict(self.hashes.get(key, {}))
|
||
|
||
def hvals(self, key):
|
||
return list(self.hashes.get(key, {}).values())
|
||
|
||
def hlen(self, key):
|
||
return len(self.hashes.get(key, {}))
|
||
|
||
def hexists(self, key, field):
|
||
return field in self.hashes.get(key, {})
|
||
|
||
def hdel(self, key, field):
|
||
self.hashes.get(key, {}).pop(field, None)
|
||
|
||
def sadd(self, key, value):
|
||
self.sets.setdefault(key, set()).add(value)
|
||
|
||
def srem(self, key, value):
|
||
self.sets.setdefault(key, set()).discard(value)
|
||
|
||
def scard(self, key):
|
||
return len(self.sets.get(key, set()))
|
||
|
||
def smembers(self, key):
|
||
return set(self.sets.get(key, set()))
|
||
|
||
def sismember(self, key, value):
|
||
return value in self.sets.get(key, set())
|
||
|
||
def exists(self, key):
|
||
return int(key in self.hashes or key in self.lists or key in self.sets)
|
||
|
||
def publish(self, channel, message):
|
||
self.published.append((channel, message))
|
||
|
||
def keys(self, pattern="*"):
|
||
result = []
|
||
for name in list(self.lists) + list(self.hashes) + list(self.sets):
|
||
if pattern == "*" or name.startswith(pattern.replace("*", "")):
|
||
result.append(name)
|
||
return result
|
||
|
||
def scan_iter(self, match="*"):
|
||
for key in self.keys(match):
|
||
yield key
|
||
|
||
def setex(self, key, ttl, value):
|
||
pass
|
||
|
||
def pipeline(self, transaction=True):
|
||
return _FakePipeline(self)
|
||
|
||
@staticmethod
|
||
def lock(lock_key, timeout=5, blocking_timeout=1):
|
||
class _FakeLock:
|
||
def __enter__(self2):
|
||
pass
|
||
|
||
def __exit__(self2, *args):
|
||
pass
|
||
|
||
return _FakeLock()
|
||
|
||
|
||
class _FakePipeline:
|
||
"""模拟 Redis pipeline"""
|
||
|
||
def __init__(self, redis_conn):
|
||
self._redis = redis_conn
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
pass
|
||
|
||
def sadd(self, key, value):
|
||
self._redis.sadd(key, value)
|
||
return self
|
||
|
||
def srem(self, key, value):
|
||
self._redis.srem(key, value)
|
||
return self
|
||
|
||
def hset(self, key, field, value):
|
||
self._redis.hset(key, field, value)
|
||
return self
|
||
|
||
def hdel(self, key, field):
|
||
self._redis.hdel(key, field)
|
||
return self
|
||
|
||
def delete(self, key):
|
||
self._redis.delete(key)
|
||
return self
|
||
|
||
def expire(self, key, ttl):
|
||
return self
|
||
|
||
def execute(self):
|
||
pass
|
||
|
||
|
||
class StubAnalytics:
|
||
"""模拟 Analytics 服务"""
|
||
|
||
def __init__(self, pending=None, model=None, qualified=None):
|
||
self.pending = pending or []
|
||
self.model = model or []
|
||
self.qualified = qualified or []
|
||
self.enqueued_incremental = []
|
||
|
||
def list_pending_collection_tasks(self):
|
||
return list(self.pending)
|
||
|
||
def list_model_eligible_apps(self):
|
||
return list(self.model)
|
||
|
||
def enqueue_incremental(self, package_name, **kwargs):
|
||
self.enqueued_incremental.append((package_name, kwargs))
|
||
|
||
|
||
class StubMonitor:
|
||
"""模拟 Monitoring 服务"""
|
||
|
||
def __init__(self):
|
||
self.failure_buckets: dict = {}
|
||
self.assigned_tasks: list = []
|
||
self.reports: list = []
|
||
|
||
def set_failure_bucket(self, task_key, bucket):
|
||
self.failure_buckets[task_key] = bucket
|
||
|
||
def handle_task_assigned(self, worker_id, task_key, **kwargs):
|
||
self.assigned_tasks.append((worker_id, task_key))
|
||
|
||
def handle_worker_report(self, **kwargs):
|
||
self.reports.append(kwargs)
|
||
|
||
def set_worker_state(self, worker_id, state, **kwargs):
|
||
pass
|
||
|
||
def register_worker(self, worker_id, **kwargs):
|
||
pass
|
||
|
||
def mark_worker_offline(self, worker_id, reason):
|
||
pass
|
||
|
||
def close_controller_session(self, **kwargs):
|
||
pass
|
||
|
||
def start_controller_session(self, **kwargs):
|
||
pass
|
||
|
||
def heartbeat_controller_session(self):
|
||
pass
|
||
|
||
|
||
class StubNotifier:
|
||
def send_weCom_alert(self, *args, **kwargs):
|
||
pass
|
||
|
||
def send_weChat_alert(self, *args, **kwargs):
|
||
pass
|
||
|
||
|
||
class StubAnalyticsRepo:
|
||
def get_collection_row(self, package_name):
|
||
return None
|
||
|
||
def get_latest_task_execution(self, package_name):
|
||
return None
|
||
|
||
def has_successful_related_magic_label(self, app_magic_label, package_name):
|
||
return False
|
||
|
||
def list_qualified_apps(self):
|
||
return list(self.qualified_apps) if hasattr(self, 'qualified_apps') else []
|
||
|
||
|
||
class StubAnalyticsWithRepo:
|
||
def __init__(self, qualified_apps=None):
|
||
self.repo = StubAnalyticsRepo()
|
||
if qualified_apps:
|
||
self.repo.qualified_apps = qualified_apps
|
||
self.enqueued_incremental = []
|
||
|
||
def list_pending_collection_tasks(self):
|
||
return []
|
||
|
||
def list_model_eligible_apps(self):
|
||
return []
|
||
|
||
def list_qualified_apps(self):
|
||
return self.repo.list_qualified_apps()
|
||
|
||
def enqueue_incremental(self, **kwargs):
|
||
self.enqueued_incremental.append(kwargs)
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
|
||
def make_dispatcher(redis_conn=None, analytics=None, monitor=None, notifier=None):
|
||
"""构建一个最小化的 RedisTaskDispatcher 用于测试"""
|
||
from redis_task_distribute import RedisTaskDispatcher
|
||
|
||
dispatcher = RedisTaskDispatcher.__new__(RedisTaskDispatcher)
|
||
dispatcher.redis = redis_conn or FakeRedis()
|
||
dispatcher.analytics = analytics or StubAnalytics()
|
||
dispatcher.monitor = monitor or StubMonitor()
|
||
dispatcher.notifier = notifier or StubNotifier()
|
||
dispatcher.worker_inventory = {}
|
||
dispatcher.managed_worker_ids = set()
|
||
dispatcher.only_managed_workers_can_dispatch = False
|
||
dispatcher.worker_online_timeout = 300
|
||
dispatcher.task_routing_rules = {"package_name": {}, "task_key": {}}
|
||
dispatcher._apk_registry = None
|
||
dispatcher._minio_storage = None
|
||
|
||
import types
|
||
def _mark_noop(self, package_name, app_name="", last_updated=""):
|
||
return None
|
||
dispatcher._mark_download_error_awaiting_apk = types.MethodType(_mark_noop, dispatcher)
|
||
|
||
def _dispatch_alert_noop(self, *args, **kwargs):
|
||
pass
|
||
dispatcher._dispatch_alert = types.MethodType(_dispatch_alert_noop, dispatcher)
|
||
|
||
def _registry_is_fresh_enough_noop(package_name, last_updated):
|
||
return False
|
||
class _StubRegistry:
|
||
is_fresh_enough = staticmethod(_registry_is_fresh_enough_noop)
|
||
dispatcher._get_apk_registry = lambda: _StubRegistry
|
||
return dispatcher
|
||
|
||
|
||
def qualified_entry(app_name, package_name, country_code="US", device_type="emulator"):
|
||
"""构建一个合格的 app 条目"""
|
||
return {
|
||
"package_name": package_name,
|
||
"app_name": app_name,
|
||
"task_payload": {
|
||
"app_name": app_name,
|
||
"package_name": package_name,
|
||
"country_code": country_code,
|
||
"device_type": device_type,
|
||
"original_row": {},
|
||
},
|
||
}
|
||
|
||
|
||
# =============================================================
|
||
# 1. Block 任务加载测试
|
||
# =============================================================
|
||
|
||
|
||
class TestBlockTaskLoading:
|
||
"""测试 block 任务的加载行为"""
|
||
|
||
def test_block_task_has_correct_task_key_suffix(self):
|
||
"""block 任务 key 以 _block 结尾"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [qualified_entry("TestApp", "com.test.block")]
|
||
count = dispatcher.load_block_tasks(entries)
|
||
assert count == 1
|
||
|
||
expected_key = "TestApp_com.test.block_block"
|
||
assert redis_conn.hexists("task:details", expected_key), (
|
||
f"expected task_key '{expected_key}' in task:details"
|
||
)
|
||
|
||
def test_block_tasks_pushed_to_high_priority_queue(self):
|
||
"""block 任务入高优队列"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [
|
||
qualified_entry("App1", "com.app1"),
|
||
qualified_entry("App2", "com.app2"),
|
||
]
|
||
count = dispatcher.load_block_tasks(entries)
|
||
assert count == 2
|
||
|
||
high_queue = redis_conn.lrange("task:queue:high", 0, -1)
|
||
assert len(high_queue) == 2
|
||
for task_key in high_queue:
|
||
assert task_key.endswith("_block")
|
||
|
||
def test_block_task_details_contain_is_block_task_flag(self):
|
||
"""task_details 包含 is_block_task=True"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [qualified_entry("BlockApp", "com.block.test")]
|
||
dispatcher.load_block_tasks(entries)
|
||
|
||
task_key = "BlockApp_com.block.test_block"
|
||
details_json = redis_conn.hget("task:details", task_key)
|
||
details = json.loads(details_json)
|
||
assert details.get("is_block_task") is True
|
||
assert details.get("task_queue") == "high"
|
||
|
||
def test_block_task_has_app_name_and_package_in_details(self):
|
||
"""task_details 保留原始 app_name 和 package_name"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [
|
||
qualified_entry("MyApp", "com.company.myapp", country_code="CN", device_type="emulator")
|
||
]
|
||
dispatcher.load_block_tasks(entries)
|
||
|
||
task_key = "MyApp_com.company.myapp_block"
|
||
details = json.loads(redis_conn.hget("task:details", task_key))
|
||
assert details["app_name"] == "MyApp"
|
||
assert details["package_name"] == "com.company.myapp"
|
||
assert details["country_code"] == "CN"
|
||
assert details["device_type"] == "emulator"
|
||
|
||
def test_duplicate_block_tasks_are_skipped(self):
|
||
"""重复加载 block 任务先清除旧任务再重新加载,每次 count 相同"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [qualified_entry("DupApp", "com.dup")]
|
||
assert dispatcher.load_block_tasks(entries) == 1
|
||
# 第二次加载先清除旧任务再重新加载,不是简单地跳过
|
||
assert dispatcher.load_block_tasks(entries) == 1
|
||
# 队列中只有一个任务(不是两个)
|
||
high_queue = redis_conn.lrange("task:queue:high", 0, -1)
|
||
assert len(high_queue) == 1
|
||
|
||
def test_load_all_qualified_block_tasks_calls_repo(self):
|
||
"""load_all_qualified_block_tasks 从 DB 查询 qualified apps"""
|
||
redis_conn = FakeRedis()
|
||
analytics = StubAnalyticsWithRepo(qualified_apps=[
|
||
qualified_entry("QualApp", "com.qual"),
|
||
qualified_entry("AnotherQual", "com.another"),
|
||
])
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn, analytics=analytics)
|
||
|
||
count = dispatcher.load_all_qualified_block_tasks()
|
||
assert count == 2
|
||
|
||
assert redis_conn.hexists("task:details", "QualApp_com.qual_block")
|
||
assert redis_conn.hexists("task:details", "AnotherQual_com.another_block")
|
||
|
||
|
||
# =============================================================
|
||
# 2. Worker 接收 payload 测试
|
||
# =============================================================
|
||
|
||
|
||
class TestBlockTaskPayload:
|
||
"""测试 Worker 收到的 task payload"""
|
||
|
||
def test_task_payload_includes_traffic_root(self):
|
||
"""_task_to_payload 的 traffic_root 和 blocked 字段:block 任务用 block 路径"""
|
||
from config import ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_details = {
|
||
"app_name": "TestApp",
|
||
"package_name": "com.test",
|
||
"country_code": "US",
|
||
"is_block_task": True,
|
||
"available_sources": ["google_play", "local"],
|
||
}
|
||
payload = dispatcher._task_to_payload("TestApp_com.test_block", task_details)
|
||
assert payload["blocked"] is True
|
||
assert payload["traffic_root"] == ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
|
||
def test_normal_task_payload_uses_default_traffic_root(self):
|
||
"""普通任务的 blocked=False, traffic_root 为默认路径"""
|
||
from config import ANALYTICS_TRAFFIC_ROOT
|
||
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_details = {
|
||
"app_name": "NormalApp",
|
||
"package_name": "com.normal",
|
||
"country_code": "US",
|
||
"available_sources": ["google_play", "local"],
|
||
}
|
||
payload = dispatcher._task_to_payload("NormalApp_com.normal", task_details)
|
||
assert payload["blocked"] is False
|
||
assert payload["traffic_root"] == ANALYTICS_TRAFFIC_ROOT
|
||
|
||
def test_worker_init_receives_block_task_with_traffic_root(self):
|
||
"""Worker init 返回的 task 有正确的 traffic_root"""
|
||
from config import ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
entries = [qualified_entry("BlockInit", "com.block.init")]
|
||
dispatcher.load_block_tasks(entries)
|
||
|
||
task_key = "BlockInit_com.block.init_block"
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
worker_info = {
|
||
"worker_id": worker_id,
|
||
"ip_address": "192.168.1.1",
|
||
"mac_address": "AA:BB:CC:DD:EE:01",
|
||
"hostname": "test-host",
|
||
"platform": "Windows",
|
||
"device_type": "emulator",
|
||
"register_time": 1000000000.0,
|
||
"last_update": 1000000000.0,
|
||
"status": "idle",
|
||
"dispatch_enabled": True,
|
||
}
|
||
redis_conn.hset("workers:info", worker_id, json.dumps(worker_info))
|
||
redis_conn.sadd("workers:idle", worker_id)
|
||
|
||
task = dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-host",
|
||
platform="Windows",
|
||
device_type="emulator",
|
||
)
|
||
|
||
assert task is not None
|
||
assert task["blocked"] is True
|
||
assert task["traffic_root"] == ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
assert task["task_key"] == task_key
|
||
|
||
def test_worker_report_then_assign_next_block_task(self):
|
||
"""Worker 上报完成后领取下一个 block 任务"""
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
entries = [
|
||
qualified_entry("BlockA", "com.block.a"),
|
||
qualified_entry("BlockB", "com.block.b"),
|
||
]
|
||
dispatcher.load_block_tasks(entries)
|
||
|
||
task_a_key = "BlockA_com.block.a_block"
|
||
task_b_key = "BlockB_com.block.b_block"
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
worker_info = {
|
||
"worker_id": worker_id,
|
||
"ip_address": "192.168.1.1",
|
||
"mac_address": "AA:BB:CC:DD:EE:01",
|
||
"hostname": "test-host",
|
||
"platform": "Windows",
|
||
"device_type": "emulator",
|
||
"register_time": 1000000000.0,
|
||
"last_update": 1000000000.0,
|
||
"status": "idle",
|
||
"dispatch_enabled": True,
|
||
}
|
||
redis_conn.hset("workers:info", worker_id, json.dumps(worker_info))
|
||
redis_conn.sadd("workers:idle", worker_id)
|
||
|
||
first_task = dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-host",
|
||
platform="Windows",
|
||
device_type="emulator",
|
||
)
|
||
assert first_task is not None
|
||
|
||
second_task = dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=first_task["task_key"],
|
||
report_data={"status": "success"},
|
||
)
|
||
assert second_task is not None
|
||
from config import ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
assert second_task["traffic_root"] == ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
|
||
|
||
# =============================================================
|
||
# 3. Block 任务重试逻辑测试
|
||
# =============================================================
|
||
|
||
|
||
class TestBlockTaskRetry:
|
||
"""测试 block 任务的专用重试策略"""
|
||
|
||
def _setup_block_task(self, redis_conn, task_key, app_name="BlockApp", package_name="com.block"):
|
||
"""准备一个 block 任务的 Redis 数据"""
|
||
task_details = {
|
||
"app_name": app_name,
|
||
"package_name": package_name,
|
||
"country_code": "US",
|
||
"is_block_task": True,
|
||
"task_queue": "high",
|
||
}
|
||
redis_conn.hset("task:details", task_key, json.dumps(task_details))
|
||
redis_conn.hset("task:status", task_key, json.dumps({
|
||
"status": "running",
|
||
"worker_id": "192.168.1.1_WORKER",
|
||
"retry_count": 0,
|
||
}))
|
||
|
||
def test_block_task_download_error_goes_to_pending_retry(self):
|
||
"""block 任务的下载错误 -> pending_retry"""
|
||
from result_codes import ErrorInfo, ErrorCategory, DownloadError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="192.168.1.1_WORKER",
|
||
error_info=ErrorInfo.download(DownloadError.REGION_RESTRICTED),
|
||
message="Google Play 锁区",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "pending_retry", (
|
||
f"expected pending_retry, got {status['status']}"
|
||
)
|
||
assert monitor.failure_buckets.get(task_key) == "retry"
|
||
|
||
def test_block_task_app_error_is_permanent_fail(self):
|
||
"""block 任务的 APP_ERROR -> 永久失败"""
|
||
from result_codes import ErrorInfo, ErrorCategory, AppError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="192.168.1.1_WORKER",
|
||
error_info=ErrorInfo.app(AppError.CRASH),
|
||
message="应用闪退",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "failed"
|
||
assert redis_conn.sismember("task:failed", task_key) if hasattr(redis_conn, 'sismember') else (
|
||
task_key in redis_conn.smembers("task:failed")
|
||
)
|
||
assert monitor.failure_buckets.get(task_key) == "failed"
|
||
|
||
def test_block_task_business_error_is_permanent_fail(self):
|
||
"""block 任务的 BUSINESS_ERROR -> 永久失败"""
|
||
from result_codes import ErrorInfo, ErrorCategory, BusinessError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="192.168.1.1_WORKER",
|
||
error_info=ErrorInfo.business(BusinessError.LOGIN_FAILED),
|
||
message="登录失败",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "failed"
|
||
assert task_key in redis_conn.smembers("task:failed")
|
||
|
||
def test_block_task_infra_non_download_error_is_permanent_fail(self):
|
||
"""block 任务的非下载 INFRA_ERROR -> 永久失败"""
|
||
from result_codes import ErrorInfo, ErrorCategory, InfraError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="192.168.1.1_WORKER",
|
||
error_info=ErrorInfo.infra(InfraError.ADB_ERROR),
|
||
message="ADB 断联",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "failed"
|
||
assert task_key in redis_conn.smembers("task:failed")
|
||
|
||
def test_block_task_infra_download_failed_goes_to_retry(self):
|
||
"""block 任务 INFRA_ERROR.DOWNLOAD_FAILED -> pending_retry"""
|
||
from result_codes import ErrorInfo, ErrorCategory, InfraError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="192.168.1.1_WORKER",
|
||
error_info=ErrorInfo.infra(InfraError.DOWNLOAD_FAILED),
|
||
message="下载失败",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "pending_retry", (
|
||
f"expected pending_retry for DOWNLOAD_FAILED, got {status['status']}"
|
||
)
|
||
assert monitor.failure_buckets.get(task_key) == "retry"
|
||
|
||
def test_block_task_retry_avoids_same_worker(self):
|
||
"""block 任务下载失败重试时避开同一 worker"""
|
||
from result_codes import ErrorInfo, ErrorCategory, DownloadError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
worker_id = "192.168.2.1_FAILED_WORKER"
|
||
self._setup_block_task(redis_conn, task_key)
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id=worker_id,
|
||
error_info=ErrorInfo.download(DownloadError.APP_NOT_FOUND),
|
||
message="Google Play 未找到",
|
||
)
|
||
|
||
details = json.loads(redis_conn.hget("task:details", task_key))
|
||
assert worker_id in details.get("excluded_worker_ids", [])
|
||
|
||
def test_normal_task_retry_unchanged(self):
|
||
"""block 任务的逻辑不影响普通任务重试"""
|
||
from result_codes import ErrorInfo, ErrorCategory, AppError
|
||
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
task_key = "NormalApp_com.normal"
|
||
redis_conn.hset("task:details", task_key, json.dumps({
|
||
"app_name": "NormalApp",
|
||
"package_name": "com.normal",
|
||
"country_code": "US",
|
||
"device_type": "physical",
|
||
}))
|
||
redis_conn.hset("task:status", task_key, json.dumps({
|
||
"status": "running",
|
||
"worker_id": "worker_x",
|
||
"retry_count": 0,
|
||
}))
|
||
|
||
dispatcher._record_failed_task(
|
||
task_key=task_key,
|
||
worker_id="worker_x",
|
||
error_info=ErrorInfo.app(AppError.CRASH),
|
||
message="闪退",
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "failed"
|
||
assert task_key in redis_conn.smembers("task:failed")
|
||
|
||
|
||
# =============================================================
|
||
# 4. Analytics snapshot 跳过测试
|
||
# =============================================================
|
||
|
||
|
||
class TestAnalyticsSnapshotSkip:
|
||
"""测试 analytics snapshot 回调跳过 block 任务"""
|
||
|
||
def _setup_task(self, redis_conn, task_key, is_blocked=False):
|
||
redis_conn.hset("task:details", task_key, json.dumps({
|
||
"app_name": "Test",
|
||
"package_name": "com.test",
|
||
"is_block_task": is_blocked,
|
||
"task_queue": "high",
|
||
}))
|
||
redis_conn.hset("task:status", task_key, json.dumps({
|
||
"status": "pending_retry",
|
||
"retry_count": 1,
|
||
}))
|
||
|
||
def test_block_task_skipped_in_analytics_snapshot(self):
|
||
"""block 任务在 analytics 回调中被跳过(不重新入队)"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_task(redis_conn, task_key, is_blocked=True)
|
||
|
||
summary = {
|
||
"task_payload": {"app_name": "BlockApp", "package_name": "com.block"},
|
||
"latest_task_key": task_key,
|
||
"collection_status": "pending",
|
||
}
|
||
|
||
dispatcher._handle_analytics_snapshot("com.block", summary)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "pending_retry", (
|
||
f"block task should not be re-queued, got {status['status']}"
|
||
)
|
||
|
||
def test_block_task_suffix_also_skipped(self):
|
||
"""以 _block 结尾的任务同样被 analytics 跳过"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_key = "SomeApp_com.some_block"
|
||
redis_conn.hset("task:details", task_key, json.dumps({
|
||
"app_name": "SomeApp",
|
||
"package_name": "com.some",
|
||
}))
|
||
|
||
summary = {
|
||
"latest_task_key": task_key,
|
||
"collection_status": "pending",
|
||
}
|
||
|
||
dispatcher._handle_analytics_snapshot("com.some", summary)
|
||
|
||
assert not redis_conn.hexists("task:status", task_key), (
|
||
"block-suffix task should be skipped entirely (status not set)"
|
||
)
|
||
|
||
def test_normal_task_still_processed_by_analytics(self):
|
||
"""普通任务在 analytics 回调中照常处理"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_key = "NormalApp_com.normal"
|
||
self._setup_task(redis_conn, task_key, is_blocked=False)
|
||
|
||
summary = {
|
||
"task_payload": {"app_name": "NormalApp", "package_name": "com.normal"},
|
||
"latest_task_key": task_key,
|
||
"collection_status": "qualified",
|
||
}
|
||
|
||
dispatcher._handle_analytics_snapshot("com.normal", summary)
|
||
|
||
assert task_key in redis_conn.smembers("task:completed")
|
||
|
||
def test_block_task_redis_state_unchanged_by_callback(self):
|
||
"""block 任务无论 collection_status 如何 Redis 状态不变"""
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
task_key = "BlockApp_com.block_block"
|
||
self._setup_task(redis_conn, task_key, is_blocked=True)
|
||
|
||
for status in ["qualified", "failed_terminal", "pending"]:
|
||
summary = {
|
||
"task_payload": {"app_name": "BlockApp", "package_name": "com.block"},
|
||
"latest_task_key": task_key,
|
||
"collection_status": status,
|
||
}
|
||
dispatcher._handle_analytics_snapshot("com.block", summary)
|
||
|
||
task_status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert task_status["status"] == "pending_retry", (
|
||
f"status should stay pending_retry (not affected by analytics), "
|
||
f"got {task_status['status']} for collection_status={status}"
|
||
)
|
||
|
||
|
||
# =============================================================
|
||
# 5. Block 测试报告生成测试
|
||
# =============================================================
|
||
|
||
|
||
class TestBlockReport:
|
||
"""测试 block 测试报告 CSV 生成"""
|
||
|
||
def test_report_includes_block_tasks_only(self, tmp_path):
|
||
"""报告只包含 _block 后缀的任务"""
|
||
report_path = os.path.join(str(tmp_path), "block_test_report.csv")
|
||
|
||
from dispatcher_main import generate_block_test_report
|
||
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
for app_name, package_name, suffix in (
|
||
("BlockedA", "com.block.a", "_block"),
|
||
("BlockedB", "com.block.b", "_block"),
|
||
("NormalApp", "com.normal", ""),
|
||
):
|
||
task_key = f"{app_name}_{package_name}{suffix}"
|
||
redis_conn.hset("task:details", task_key, json.dumps({
|
||
"app_name": app_name,
|
||
"package_name": package_name,
|
||
"is_block_task": suffix == "_block",
|
||
}))
|
||
redis_conn.hset("task:status", task_key, json.dumps({
|
||
"status": "success" if suffix == "_block" else "completed",
|
||
"worker_id": "worker_x",
|
||
"start_time": 1000.0,
|
||
"end_time": 1100.0,
|
||
"retry_count": 0,
|
||
}))
|
||
redis_conn.hset("workers:info", "worker_x", json.dumps({
|
||
"ip_address": "192.168.1.1",
|
||
}))
|
||
|
||
generate_block_test_report(dispatcher, report_dir=str(tmp_path))
|
||
|
||
assert os.path.exists(report_path)
|
||
with open(report_path, "r", encoding="utf-8") as f:
|
||
reader = csv.reader(f)
|
||
rows = list(reader)
|
||
|
||
assert len(rows) >= 3
|
||
headers = rows[0]
|
||
assert "任务键" in headers
|
||
assert "应用名称" in headers
|
||
assert "状态" in headers
|
||
assert "失败原因" in headers
|
||
|
||
task_keys_in_report = [row[0] for row in rows[1:] if row and row[0]]
|
||
assert "BlockedA_com.block.a_block" in task_keys_in_report
|
||
assert "BlockedB_com.block.b_block" in task_keys_in_report
|
||
assert "NormalApp_com.normal" not in task_keys_in_report
|
||
|
||
def test_report_counts_success_and_failure(self, tmp_path):
|
||
"""报告正确统计成功/失败/重试数量"""
|
||
report_path = os.path.join(str(tmp_path), "block_test_report.csv")
|
||
|
||
from dispatcher_main import generate_block_test_report
|
||
|
||
redis_conn = FakeRedis()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn)
|
||
|
||
block_data = [
|
||
("SuccessApp", "com.success", "success"),
|
||
("FailedApp", "com.failed", "failed"),
|
||
("RetryingApp", "com.retry", "pending_retry"),
|
||
("PendingApp", "com.pending", "pending"),
|
||
]
|
||
for app_name, pkg, status in block_data:
|
||
task_key = f"{app_name}_{pkg}_block"
|
||
redis_conn.hset("task:details", task_key, json.dumps({
|
||
"app_name": app_name,
|
||
"package_name": pkg,
|
||
"is_block_task": True,
|
||
}))
|
||
fail_message = "App crashed" if status == "failed" else ""
|
||
redis_conn.hset("task:status", task_key, json.dumps({
|
||
"status": status,
|
||
"worker_id": "worker_x",
|
||
"last_fail_message": fail_message,
|
||
"retry_count": 0,
|
||
}))
|
||
|
||
generate_block_test_report(dispatcher, report_dir=str(tmp_path))
|
||
|
||
with open(report_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
assert "成功,1" in content
|
||
assert "失败,1" in content
|
||
assert "重试中/待分发,2" in content
|
||
|
||
|
||
# =============================================================
|
||
# 6. 集成测试: 模拟 Worker 完整接收 block 任务流程
|
||
# =============================================================
|
||
|
||
|
||
class TestWorkerBlockTaskIntegration:
|
||
"""模拟 Worker 完整的 block 任务接收-执行-上报 链路"""
|
||
|
||
def test_worker_receives_block_task_params(self):
|
||
"""Worker 通过 init 拿到的 block 任务包含所有预期字段"""
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
dispatcher = make_dispatcher(redis_conn=redis_conn, monitor=monitor)
|
||
|
||
entries = [qualified_entry("FullBlock", "com.full.block", country_code="CN", device_type="emulator")]
|
||
dispatcher.load_block_tasks(entries)
|
||
task_key = "FullBlock_com.full.block_block"
|
||
|
||
worker_id = "192.168.1.100_EE:FF:00:11:22:33"
|
||
redis_conn.hset("workers:info", worker_id, json.dumps({
|
||
"worker_id": worker_id,
|
||
"ip_address": "192.168.1.100",
|
||
"mac_address": "EE:FF:00:11:22:33",
|
||
"hostname": "block-test",
|
||
"platform": "Windows",
|
||
"device_type": "emulator",
|
||
"register_time": 1000000000.0,
|
||
"last_update": 1000000000.0,
|
||
"status": "idle",
|
||
"dispatch_enabled": True,
|
||
}))
|
||
redis_conn.sadd("workers:idle", worker_id)
|
||
|
||
task = dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.100",
|
||
mac_address="EE:FF:00:11:22:33",
|
||
hostname="block-test",
|
||
platform="Windows",
|
||
device_type="emulator",
|
||
)
|
||
|
||
assert task is not None
|
||
from config import ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
assert task["traffic_root"] == ANALYTICS_TRAFFIC_ROOT_BLOCK
|
||
assert task["task_key"] == task_key
|
||
assert task["app_name"] == "FullBlock"
|
||
assert task["package_name"] == "com.full.block"
|
||
assert task["country_code"] == "CN"
|
||
assert task["device_type"] == "emulator"
|
||
|
||
def test_full_lifecycle_block_task_to_failure(self):
|
||
"""Block 任务完整生命周期: init → report failure → check not re-queued"""
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
entries = [qualified_entry("LifecycleApp", "com.lifecycle")]
|
||
dispatcher.load_block_tasks(entries)
|
||
task_key = "LifecycleApp_com.lifecycle_block"
|
||
|
||
worker_id = "192.168.1.200_AA:11:BB:22:CC:33"
|
||
redis_conn.hset("workers:info", worker_id, json.dumps({
|
||
"worker_id": worker_id,
|
||
"ip_address": "192.168.1.200",
|
||
"mac_address": "AA:11:BB:22:CC:33",
|
||
"hostname": "lifecycle-host",
|
||
"platform": "Windows",
|
||
"device_type": "emulator",
|
||
"register_time": 1000000000.0,
|
||
"last_update": 1000000000.0,
|
||
"status": "idle",
|
||
"dispatch_enabled": True,
|
||
}))
|
||
redis_conn.sadd("workers:idle", worker_id)
|
||
|
||
task = dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.200",
|
||
mac_address="AA:11:BB:22:CC:33",
|
||
hostname="lifecycle-host",
|
||
platform="Windows",
|
||
device_type="emulator",
|
||
)
|
||
assert task is not None
|
||
|
||
next_task = dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task_key,
|
||
report_data={
|
||
"status": "failed",
|
||
"error": {
|
||
"category": "APP_ERROR",
|
||
"code": 1,
|
||
"reason": "应用闪退",
|
||
},
|
||
},
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "failed"
|
||
assert task_key in redis_conn.smembers("task:failed")
|
||
|
||
assert next_task is None, (
|
||
f"after block task fails, worker should get no next task (queue empty), "
|
||
f"got {next_task}"
|
||
)
|
||
|
||
def test_block_task_download_failure_still_in_pending_retry(self):
|
||
"""Block 任务下载失败后状态为 pending_retry,等待 APK"""
|
||
redis_conn = FakeRedis()
|
||
monitor = StubMonitor()
|
||
notifier = StubNotifier()
|
||
dispatcher = make_dispatcher(
|
||
redis_conn=redis_conn, monitor=monitor, notifier=notifier
|
||
)
|
||
|
||
entries = [qualified_entry("DownloadFail", "com.dl.fail")]
|
||
dispatcher.load_block_tasks(entries)
|
||
task_key = "DownloadFail_com.dl.fail_block"
|
||
|
||
worker_id = "192.168.1.201_WORKER"
|
||
redis_conn.hset("workers:info", worker_id, json.dumps({
|
||
"worker_id": worker_id,
|
||
"ip_address": "192.168.1.201",
|
||
"mac_address": "EE:FF:GG:HH:II:JJ",
|
||
"hostname": "dl-fail-host",
|
||
"platform": "Windows",
|
||
"device_type": "emulator",
|
||
"register_time": 1000000000.0,
|
||
"last_update": 1000000000.0,
|
||
"status": "idle",
|
||
"dispatch_enabled": True,
|
||
}))
|
||
redis_conn.sadd("workers:idle", worker_id)
|
||
|
||
task = dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.201",
|
||
mac_address="EE:FF:GG:HH:II:JJ",
|
||
hostname="dl-fail-host",
|
||
platform="Windows",
|
||
device_type="emulator",
|
||
)
|
||
assert task is not None
|
||
|
||
next_task = dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task_key,
|
||
report_data={
|
||
"status": "failed",
|
||
"error": {
|
||
"category": "DOWNLOAD_ERROR",
|
||
"code": 1,
|
||
"reason": "Google Play 锁区",
|
||
},
|
||
},
|
||
)
|
||
|
||
status = json.loads(redis_conn.hget("task:status", task_key))
|
||
assert status["status"] == "pending_retry", (
|
||
f"download error should leave task in pending_retry, got {status['status']}"
|
||
)
|
||
assert task_key not in redis_conn.smembers("task:failed")
|