autool-dispatcher/tests/test_block_task.py
2026-06-17 19:50:39 +08:00

1157 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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")