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

967 lines
46 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.

"""
app_download_record 表 + ApkRegistry 新增方法 + dispatcher 集成的完整测试。
用法:
uv run python -m pytest tests/test_download_record.py -v
uv run python -m pytest tests/test_download_record.py -v -k "TestMinio" # 仅 MinIO 测试
"""
import json
import os
import shutil
import sqlite3
import sys
import tempfile
import threading
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List
from zoneinfo import ZoneInfo
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from apk_cloud.registry import ApkRegistry, _is_apk_stale, _parse_date, _last_updated_ts
try:
from apk_cloud.storage import MinioStorage
_HAS_MINIO = True
except Exception:
_HAS_MINIO = False
try:
from redis_task_distribute import RedisTaskDispatcher
_HAS_DISPATCHER = True
except Exception:
_HAS_DISPATCHER = False
# ── helpers ──────────────────────────────────────────────────────────
@pytest.fixture
def tmp_db_path():
fd, path = tempfile.mkstemp(suffix=".sqlite3")
os.close(fd)
yield path
try:
os.unlink(path)
except OSError:
pass
@pytest.fixture
def tmp_storage_dir():
d = tempfile.mkdtemp(prefix="apk_test_")
yield d
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture
def registry(tmp_db_path, tmp_storage_dir):
r = ApkRegistry(db_path=tmp_db_path, storage_dir=tmp_storage_dir)
yield r
try:
os.unlink(tmp_db_path)
except OSError:
pass
@pytest.fixture(autouse=True)
def _cleanup_download_records(registry):
"""每个测试后清理 download_record确保测试隔离。"""
yield
with registry._connect() as conn:
conn.execute("DELETE FROM app_download_record")
def _make_result(status: str = "ok", sources: Dict[str, Dict[str, Any]] = None,
files: List[Dict[str, Any]] = None, download_date: str = "",
version_name: str = "") -> Dict[str, Any]:
files = files if files is not None else [{"filename": "base.apk", "size": 5000000}]
return {
"status": status,
"download_date": download_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version_name": version_name,
"files": files,
"source_details": sources or {},
}
# ══════════════════════════════════════════════════════════════════════
# 1. Schema & 表结构
# ══════════════════════════════════════════════════════════════════════
class TestSchema:
def test_table_exists(self, registry):
with registry._connect() as conn:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_download_record'"
).fetchone()
assert row is not None, "app_download_record table should exist"
def test_table_columns(self, registry):
expected = {
"id", "package_name", "country_code", "app_name", "last_updated",
"downloads", "worker_play_status", "worker_play_error", "worker_play_date",
"us_play_status", "us_play_error", "us_play_date",
"us_aurora_status", "us_aurora_error", "us_aurora_date",
"apkpure_status", "apkpure_error", "apkpure_date",
"overall_status", "overall_error", "apk_version_name",
"local_apk_dir", "local_apk_file_count", "local_apk_is_stale",
"created_at", "updated_at",
}
with registry._connect() as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(app_download_record)")}
assert expected == cols, f"Missing: {expected - cols}, Extra: {cols - expected}"
def test_unique_constraint(self, registry):
now = time.time()
registry.ensure_download_record("com.test", "US", "App", "2026-05-20", 100)
with pytest.raises(sqlite3.IntegrityError):
with registry._connect() as conn:
conn.execute(
"INSERT INTO app_download_record (package_name, country_code, "
"last_updated, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
("com.test", "US", "2026-05-20", now, now),
)
def test_default_values(self, registry):
registry.ensure_download_record("com.test", "US")
record = registry.get_download_record("com.test", "US")
assert record["worker_play_status"] == "pending"
assert record["us_play_status"] == "pending"
assert record["us_aurora_status"] == "pending"
assert record["overall_status"] == "pending"
assert record["local_apk_is_stale"] == 1
assert record["downloads"] == 0
assert record["local_apk_file_count"] == 0
def test_indexes_exist(self, registry):
with registry._connect() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' "
"AND tbl_name='app_download_record'"
).fetchall()
indexes = {row["name"] for row in rows}
expected = {
"idx_download_record_pkg",
"idx_download_record_country",
"idx_download_record_overall",
"idx_download_record_stale",
}
assert expected.issubset(indexes), f"Missing indexes: {expected - indexes}"
def test_create_and_drop_does_not_break(self, registry):
"""验证 DROP + CREATE 不会破坏其他表的数据。"""
registry.mark_available("com.test.keep", "2026-05-19", "/tmp/dpi/mumu_apk", "1.0", source="test")
with registry._connect() as conn:
conn.execute("DROP TABLE IF EXISTS app_download_record")
with registry._connect() as conn:
assert conn.execute(
"SELECT 1 FROM apk_registry WHERE package_name='com.test.keep'"
).fetchone() is not None
registry._ensure_tables()
with registry._connect() as conn:
assert conn.execute(
"SELECT 1 FROM apk_registry WHERE package_name='com.test.keep'"
).fetchone() is not None
registry.mark_unavailable("com.test.keep")
# ══════════════════════════════════════════════════════════════════════
# 2. ensure_download_record
# ══════════════════════════════════════════════════════════════════════
class TestEnsureDownloadRecord:
def test_create_new_record(self, registry):
registry.ensure_download_record(
"com.test.create", "US",
app_name="Test App", last_updated="2026-05-20", downloads=5000000,
)
record = registry.get_download_record("com.test.create", "US")
assert record is not None
assert record["app_name"] == "Test App"
assert record["last_updated"] == "2026-05-20"
assert record["downloads"] == 5000000
assert record["country_code"] == "US"
assert record["created_at"] > 0
assert record["updated_at"] > 0
def test_update_existing_record(self, registry):
registry.ensure_download_record("com.test.update", "CN", "Old", "2026-05-01", 100)
registry.ensure_download_record("com.test.update", "CN", "New", "2026-05-25", 200)
record = registry.get_download_record("com.test.update", "CN")
assert record["app_name"] == "New"
assert record["last_updated"] == "2026-05-25"
assert record["downloads"] == 200
assert len(registry.list_download_records()) == 1
def test_same_package_different_country(self, registry):
registry.ensure_download_record("com.test", "US", "App US", "2026-05-01", 100)
registry.ensure_download_record("com.test", "CN", "App CN", "2026-05-15", 200)
assert registry.get_download_record("com.test", "US")["app_name"] == "App US"
assert registry.get_download_record("com.test", "CN")["app_name"] == "App CN"
assert len(registry.list_download_records()) == 2
def test_empty_package_ignored(self, registry):
registry.ensure_download_record("", "US")
registry.ensure_download_record(" ", "US")
assert len(registry.list_download_records()) == 0
def test_default_values_on_minimal_args(self, registry):
registry.ensure_download_record("com.test.minimal", "US")
record = registry.get_download_record("com.test.minimal", "US")
assert record["app_name"] == ""
assert record["last_updated"] == ""
assert record["downloads"] == 0
def test_country_code_normalization(self, registry):
"""ensure_download_record 会 strip country_code 前后空格。"""
registry.ensure_download_record("com.test", " US ")
record = registry.get_download_record("com.test", "US")
assert record is not None, "should match after strip"
# ══════════════════════════════════════════════════════════════════════
# 3. update_source_result + _recompute_overall
# ══════════════════════════════════════════════════════════════════════
class TestUpdateSourceResult:
SRC_WORKER = "worker_play"
SRC_US_PLAY = "us_play"
SRC_US_AURORA = "us_aurora"
def setup_method(self):
self._today = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def test_single_source_success(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"success", download_date=self._today)
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "success"
assert r["us_play_date"] == self._today
assert r["overall_status"] == "success"
def test_single_source_failure(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"failed", error="region restricted")
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "failed"
assert r["us_play_error"] == "region restricted"
assert r["us_play_date"] is None
assert r["overall_status"] == "all_failed"
def test_error_cleared_on_success_transition(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"failed", error="timeout")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"success", download_date=self._today)
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "success"
assert r["us_play_error"] is None
assert r["overall_status"] == "success"
def test_date_cleared_on_failure_transition(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"success", download_date=self._today)
registry.update_source_result("com.test", "US", self.SRC_US_PLAY,
"failed", error="timeout")
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "failed"
assert r["us_play_date"] is None
def test_not_attempted_preserves_pending_overall(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY, "not_attempted")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "pending"
def test_one_success_overrides_failures(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY, "failed", error="e1")
registry.update_source_result("com.test", "US", self.SRC_US_AURORA, "failed", error="e2")
registry.update_source_result("com.test", "US", self.SRC_WORKER, "success",
download_date=self._today)
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "success"
assert r["overall_error"] == ""
def test_all_sources_failed_overall(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_WORKER, "failed", error="w_err")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY, "failed", error="p_err")
registry.update_source_result("com.test", "US", self.SRC_US_AURORA, "failed", error="a_err")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "all_failed"
assert "Worker Play" in (r["overall_error"] or "")
assert "w_err" in (r["overall_error"] or "")
assert "US Play" in (r["overall_error"] or "")
assert "p_err" in (r["overall_error"] or "")
assert "US Aurora" in (r["overall_error"] or "")
assert "a_err" in (r["overall_error"] or "")
def test_unknown_source_ignored(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", "unknown_source", "success")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "pending"
def test_mixed_pending_and_failed(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY, "failed", error="err")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "all_failed"
def test_all_pending_remains_pending(self, registry):
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", self.SRC_WORKER, "pending")
registry.update_source_result("com.test", "US", self.SRC_US_PLAY, "pending")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "pending"
# ══════════════════════════════════════════════════════════════════════
# 4. record_download_from_result (MinIO JSON 解析)
# ══════════════════════════════════════════════════════════════════════
class TestRecordFromMinioResult:
def test_ok_result_with_full_source_details(self, registry):
registry.ensure_download_record("com.test", "US")
result = _make_result(status="ok", sources={
"us_play": {"status": "success", "error": None, "date": "2026-05-21 10:00:00"},
"us_aurora": {"status": "not_attempted", "error": None, "date": None},
"worker_play": {"status": "not_attempted", "error": None, "date": None},
}, download_date="2026-05-21 10:00:00", version_name="2.0")
registry.record_download_from_result("com.test", "US", result)
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "success"
assert r["overall_status"] == "success"
assert r["apk_version_name"] == "2.0"
assert r["local_apk_file_count"] == 1
def test_failed_result_with_source_details(self, registry):
registry.ensure_download_record("com.test", "US")
result = _make_result(status="failed", sources={
"us_play": {"status": "failed", "error": "region restricted", "date": None},
"us_aurora": {"status": "failed", "error": "app not found", "date": None},
})
registry.record_download_from_result("com.test", "US", result)
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "failed"
assert r["us_play_error"] == "region restricted"
assert r["us_aurora_status"] == "failed"
assert r["us_aurora_error"] == "app not found"
assert r["overall_status"] == "all_failed"
def test_ok_result_no_local_update_when_missing_files(self, registry):
registry.ensure_download_record("com.test", "US")
result = _make_result(status="ok", sources={
"us_play": {"status": "success", "error": None, "date": "2026-05-21"},
}, files=[], version_name="1.0")
registry.record_download_from_result("com.test", "US", result)
r = registry.get_download_record("com.test", "US")
assert r["us_play_status"] == "success"
assert r["local_apk_file_count"] == 0
assert r["apk_version_name"] == "1.0"
def test_result_with_no_source_details(self, registry):
"""旧格式兼容:无 source_details 时不报错。"""
registry.ensure_download_record("com.test", "US")
result = {"status": "ok", "download_date": "2026-05-21"} # No source_details
registry.record_download_from_result("com.test", "US", result)
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "pending"
def test_empty_package_name_ignored(self, registry):
registry.record_download_from_result("", "US", _make_result())
assert len(registry.list_download_records()) == 0
def test_source_details_is_list_handled_gracefully(self, registry):
"""不合法类型的 source_details 应被安全跳过。"""
registry.ensure_download_record("com.test", "US")
result = {"status": "ok", "source_details": [1, 2, 3]}
registry.record_download_from_result("com.test", "US", result)
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "pending"
# ══════════════════════════════════════════════════════════════════════
# 5. Stale Detection & Cleanup
# ══════════════════════════════════════════════════════════════════════
class TestStaleDetection:
def test_stale_when_download_older_than_last_updated(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-20")
registry.mark_available("com.test", "2026-05-01", "/tmp/dpi/mumu_apk", "1.0")
records = registry.get_stale_records()
assert any(r["package_name"] == "com.test" for r in records)
def test_not_stale_when_download_newer_or_equal(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-01")
registry.mark_available("com.test", "2026-05-01", "/tmp/dpi/mumu_apk", "1.0")
records = registry.get_stale_records()
assert not any(r["package_name"] == "com.test" for r in records)
def test_not_stale_when_download_newer(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-01")
registry.mark_available("com.test", "2026-05-15", "/tmp/dpi/mumu_apk", "2.0")
records = registry.get_stale_records()
assert not any(r["package_name"] == "com.test" for r in records)
def test_stale_when_no_apk_registry_entry(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-01")
records = registry.get_stale_records()
assert any(r["package_name"] == "com.test" for r in records)
def test_stale_records_sorted_by_downloads(self, registry):
registry.ensure_download_record("com.low", "US", "Low", "2026-05-20", downloads=100)
registry.ensure_download_record("com.high", "US", "High", "2026-05-20", downloads=5000000)
registry.ensure_download_record("com.mid", "US", "Mid", "2026-05-20", downloads=10000)
records = registry.get_stale_records()
downloads_order = [r["downloads"] for r in records if r["package_name"] in
("com.low", "com.mid", "com.high")]
assert downloads_order == sorted(downloads_order, reverse=True)
def test_mark_available_refreshes_stale_flag(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-20")
registry.mark_available("com.test", "2026-05-01", "/tmp", "1.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 1
registry.mark_available("com.test", "2026-05-25", "/tmp", "2.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 0
def test_mark_unavailable_marks_all_records_stale(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-01")
registry.ensure_download_record("com.test", "CN", "App", "2026-05-01")
registry.mark_available("com.test", "2026-05-20", "/tmp", "2.0")
# First verify fresh
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 0
registry.mark_unavailable("com.test")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 1
assert registry.get_download_record("com.test", "CN")["local_apk_is_stale"] == 1
assert registry.get_download_record("com.test", "US")["local_apk_file_count"] == 0
def test__is_apk_stale_helper(self):
assert _is_apk_stale("2026-05-01", "2026-05-20") is True
assert _is_apk_stale("2026-05-20", "2026-05-01") is False
assert _is_apk_stale("2026-05-20", "2026-05-20") is False
assert _is_apk_stale("", "2026-05-01") is True
assert _is_apk_stale("2026-05-20", "") is False
def test__parse_date_variants(self):
dt = _parse_date("2026-05-20 10:30:00")
assert dt is not None and dt.year == 2026
dt = _parse_date("2026-05-20")
assert dt is not None
dt = _parse_date("2026-05")
assert dt is not None
dt = _parse_date("")
assert dt is None
dt = _parse_date(None)
assert dt is None
class TestCleanupStaleLocal:
def test_cleanup_deletes_registry_entry(self, registry, tmp_storage_dir):
pkg_dir = os.path.join(tmp_storage_dir, "com.test.cleanup")
os.makedirs(pkg_dir, exist_ok=True)
with open(os.path.join(pkg_dir, "test.apk"), "w") as f:
f.write("fake apk")
registry.ensure_download_record("com.test.cleanup", "US")
registry.mark_available("com.test.cleanup", "2026-05-01", pkg_dir, "1.0")
assert registry.get("com.test.cleanup") is not None
assert os.path.isdir(pkg_dir)
cleaned = registry.cleanup_stale_local("com.test.cleanup")
assert cleaned is True
assert registry.get("com.test.cleanup") is None
assert not os.path.isdir(pkg_dir)
def test_cleanup_nonexistent_directory(self, registry):
registry.ensure_download_record("com.test.noexist", "US")
cleaned = registry.cleanup_stale_local("com.test.noexist")
assert cleaned is True
assert registry.get("com.test.noexist") is None
# ══════════════════════════════════════════════════════════════════════
# 6. list / query methods
# ══════════════════════════════════════════════════════════════════════
class TestListAndQuery:
def test_list_all_records(self, registry):
registry.ensure_download_record("com.a", "US", downloads=100)
registry.ensure_download_record("com.b", "CN", downloads=200)
registry.ensure_download_record("com.c", "US", downloads=300)
records = registry.list_download_records()
assert len(records) == 3
def test_filter_by_country_code(self, registry):
registry.ensure_download_record("com.a", "US", downloads=100)
registry.ensure_download_record("com.b", "CN", downloads=200)
us = registry.list_download_records(country_code="US")
assert len(us) == 1
assert us[0]["package_name"] == "com.a"
def test_filter_by_overall_status(self, registry):
registry.ensure_download_record("com.a", "US")
registry.ensure_download_record("com.b", "US")
registry.update_source_result("com.a", "US", "us_play", "failed", error="err")
failed = registry.list_download_records(overall_status="all_failed")
assert len(failed) == 1
assert failed[0]["package_name"] == "com.a"
def test_filter_by_both(self, registry):
registry.ensure_download_record("com.a", "US")
registry.ensure_download_record("com.b", "CN")
registry.update_source_result("com.a", "US", "us_play", "success",
download_date="2026-05-21")
success_us = registry.list_download_records(
country_code="US", overall_status="success"
)
assert len(success_us) == 1
assert success_us[0]["package_name"] == "com.a"
def test_sorted_by_downloads_desc(self, registry):
registry.ensure_download_record("com.low", "US", downloads=100)
registry.ensure_download_record("com.high", "US", downloads=5000000)
records = registry.list_download_records()
assert records[0]["package_name"] == "com.high"
def test_get_download_record_not_found(self, registry):
assert registry.get_download_record("com.nonexistent", "US") is None
def test_get_download_record_empty_country_code(self, registry):
registry.ensure_download_record("com.test", "")
record = registry.get_download_record("com.test", "")
assert record is not None
assert record["country_code"] == ""
# ══════════════════════════════════════════════════════════════════════
# 7. refresh_from_local_disk (enhanced)
# ══════════════════════════════════════════════════════════════════════
class TestRefreshFromLocalDisk:
def test_manifest_based_refresh(self, registry, tmp_storage_dir):
pkg_dir = os.path.join(tmp_storage_dir, "com.test.manifest")
os.makedirs(pkg_dir, exist_ok=True)
manifest = {
"package_name": "com.test.manifest",
"download_date": "2026-05-20 10:00:00",
"version_name": "2.0",
"files": [{"filename": "base.apk", "size": 5000}],
"source": "local_disk",
}
with open(os.path.join(pkg_dir, "com.test.manifest.json"), "w") as f:
json.dump(manifest, f)
found = registry.refresh_from_local_disk()
assert found >= 1
entry = registry.get("com.test.manifest")
assert entry is not None
assert entry["download_date"] == "2026-05-20 10:00:00"
assert entry["version_name"] == "2.0"
assert entry["source"] == "local_disk"
def test_ctime_fallback_when_no_manifest(self, registry, tmp_storage_dir):
"""没有 manifest JSON 时应回退到 APK 文件创建时间。"""
pkg_dir = os.path.join(tmp_storage_dir, "com.test.ctime")
os.makedirs(pkg_dir, exist_ok=True)
apk_path = os.path.join(pkg_dir, "base.apk")
with open(apk_path, "w") as f:
f.write("fake content for testing ctime fallback")
apk_path2 = os.path.join(pkg_dir, "split.apk")
with open(apk_path2, "w") as f:
f.write("another fake apk")
found = registry.refresh_from_local_disk()
assert found >= 1
entry = registry.get("com.test.ctime")
assert entry is not None
assert entry["source"] == "local_disk_ctime"
assert len(entry["apk_files"]) == 2
# download_date 应为最早文件的创建时间
download_ts = _last_updated_ts(entry["download_date"])
earliest_ctime = min(os.stat(apk_path).st_ctime, os.stat(apk_path2).st_ctime)
assert abs(download_ts - earliest_ctime) < 5
def test_skip_non_apk_files(self, registry, tmp_storage_dir):
pkg_dir = os.path.join(tmp_storage_dir, "com.test.skip")
os.makedirs(pkg_dir, exist_ok=True)
with open(os.path.join(pkg_dir, "base.apk"), "w") as f:
f.write("apk")
with open(os.path.join(pkg_dir, "readme.txt"), "w") as f:
f.write("not an apk")
with open(os.path.join(pkg_dir, "config.json"), "w") as f:
f.write("{}")
found = registry.refresh_from_local_disk()
entry = registry.get("com.test.skip")
assert entry is not None
filenames = [f["filename"] for f in entry["apk_files"]]
assert "readme.txt" not in filenames
assert "config.json" not in filenames
def test_empty_directory_skipped(self, registry, tmp_storage_dir):
os.makedirs(os.path.join(tmp_storage_dir, "com.test.empty"), exist_ok=True)
found = registry.refresh_from_local_disk()
entry = registry.get("com.test.empty")
assert entry is None
def test_manifest_overrides_ctime(self, registry, tmp_storage_dir):
"""有 manifest 时优先读取 manifest不回退到 ctime。"""
pkg_dir = os.path.join(tmp_storage_dir, "com.test.both")
os.makedirs(pkg_dir, exist_ok=True)
with open(os.path.join(pkg_dir, "base.apk"), "w") as f:
f.write("apk content")
manifest = {
"package_name": "com.test.both",
"download_date": "2026-01-15 00:00:00", # 早于文件创建时间
"version_name": "1.0",
"files": [{"filename": "base.apk", "size": 11}],
"source": "local_disk",
}
with open(os.path.join(pkg_dir, "com.test.both.json"), "w") as f:
json.dump(manifest, f)
found = registry.refresh_from_local_disk()
entry = registry.get("com.test.both")
assert entry is not None
assert entry["source"] == "local_disk"
assert entry["download_date"] == "2026-01-15 00:00:00"
def test_non_existent_storage_dir(self, registry):
r = ApkRegistry(db_path=registry.db_path, storage_dir="/nonexistent/path/12345")
assert r.refresh_from_local_disk() == 0
# ══════════════════════════════════════════════════════════════════════
# 8. Edge Cases & Robustness
# ══════════════════════════════════════════════════════════════════════
class TestEdgeCases:
def test_large_download_counts(self, registry):
registry.ensure_download_record("com.test", "US", downloads=2_147_483_647)
r = registry.get_download_record("com.test", "US")
assert r["downloads"] == 2_147_483_647
def test_special_characters_in_package_name(self, registry):
pkg = "com.test.special_chars_!@#$%^&*()"
registry.ensure_download_record(pkg, "US", "App", "2026-05-20")
r = registry.get_download_record(pkg, "US")
assert r is not None
def test_long_error_messages(self, registry):
long_error = "Error: " + "x" * 5000
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", "us_play", "failed", error=long_error)
r = registry.get_download_record("com.test", "US")
assert r["us_play_error"] == long_error
def test_none_values_in_ensure(self, registry):
registry.ensure_download_record("com.test", "US", None, None, None)
r = registry.get_download_record("com.test", "US")
assert r["app_name"] == ""
assert r["last_updated"] == ""
assert r["downloads"] == 0
def test_update_source_result_with_none_package(self, registry):
registry.update_source_result("", "US", "us_play", "success")
registry.update_source_result(None, "US", "us_play", "success")
assert len(registry.list_download_records()) == 0
def test_date_formats_in_stale_check(self, registry):
registry.ensure_download_record("com.test", "US", "App", "2026-05-20")
registry.mark_available("com.test", "2026-05-01", "/tmp", "1.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 1
registry.mark_available("com.test", "2026-05-20", "/tmp", "2.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 0
# ISO format
registry.mark_available("com.test", "2026-05-20T00:00:00", "/tmp", "3.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 0
# "YYYY-MM" format → parsed as first day of month < 2026-05-20 → stale
registry.mark_available("com.test", "2026-05", "/tmp", "4.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 1
# Future date
registry.mark_available("com.test", "2026-06-01", "/tmp", "5.0")
assert registry.get_download_record("com.test", "US")["local_apk_is_stale"] == 0
def test_recomputing_with_null_error_fields(self, registry):
"""通过 update_source_result 显式调用 _recompute_overall验证 NULL error 被当作 'unknown'"""
registry.ensure_download_record("com.test", "US")
registry.update_source_result("com.test", "US", "worker_play", "failed")
registry.update_source_result("com.test", "US", "us_play", "failed")
r = registry.get_download_record("com.test", "US")
assert r["overall_status"] == "all_failed"
assert "unknown" in (r["overall_error"] or "")
# ══════════════════════════════════════════════════════════════════════
# 9. Concurrency / Thread Safety
# ══════════════════════════════════════════════════════════════════════
class TestConcurrency:
def test_concurrent_ensure_download_record(self, registry):
errors = []
def worker(idx):
try:
for i in range(10):
registry.ensure_download_record(
f"com.test.thread_{idx}", "US",
f"App{idx}", "2026-05-20", idx * 100,
)
registry.update_source_result(
f"com.test.thread_{idx}", "US",
"us_play", "success",
download_date="2026-05-21 10:00:00",
)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0, f"Errors: {errors}"
for i in range(5):
r = registry.get_download_record(f"com.test.thread_{i}", "US")
assert r is not None
assert r["us_play_status"] == "success"
def test_concurrent_update_same_record(self, registry):
registry.ensure_download_record("com.test.shared", "US", "App", "2026-05-20", 100)
errors = []
def worker():
try:
for _ in range(20):
registry.update_source_result(
"com.test.shared", "US", "us_play", "success",
download_date="2026-05-21 10:00:00",
)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
r = registry.get_download_record("com.test.shared", "US")
assert r["us_play_status"] == "success"
# ══════════════════════════════════════════════════════════════════════
# 10. Dispatcher Integration (_record_result_to_db 等)
# ══════════════════════════════════════════════════════════════════════
@pytest.mark.skipif(not _HAS_DISPATCHER, reason="redis module not available")
class TestDispatcherIntegration:
def test_record_result_to_db_with_task_context(self, registry, tmp_db_path):
"""模拟 dispatcher._record_result_to_db 的行为:从 Redis task details 获取 country_code。"""
# 在 DB 中预置 download_record
registry.ensure_download_record("com.test.dispatch", "CN", "Dispatch App", "2026-05-20", 100)
# 模拟 MinIO 结果
result = _make_result(status="ok", sources={
"us_play": {"status": "success", "error": None, "date": "2026-05-21 10:00:00"},
"us_aurora": {"status": "not_attempted", "error": None, "date": None},
})
registry.record_download_from_result("com.test.dispatch", "CN", result)
r = registry.get_download_record("com.test.dispatch", "CN")
assert r["us_play_status"] == "success"
assert r["overall_status"] == "success"
def test_record_result_without_matching_task(self, registry):
"""无匹配 task 时 country_code 为空也应正常写入。"""
result = _make_result(status="ok", sources={
"us_play": {"status": "success", "error": None, "date": "2026-05-21 10:00:00"},
})
registry.record_download_from_result("com.test.notask", "", result)
r = registry.get_download_record("com.test.notask", "")
assert r is not None
assert r["us_play_status"] == "success"
def test_get_stale_download_tasks_format(self, registry):
"""验证 _get_stale_download_tasks 返回的任务格式。"""
from redis_task_distribute import RedisTaskDispatcher
class FakeDispatcher:
_last_stale_check = None
analytics = None
def _get_apk_registry(self):
return registry
from config import APK_DOWNLOAD_QUEUE_INTERVAL
d = FakeDispatcher()
# 直接测试返回格式(需要设置 _last_stale_check 避免被 interval 跳过)
setattr(d, '_last_stale_check', 0.0)
registry.ensure_download_record("com.test.stale", "US", "Stale App", "2026-05-25", 100)
registry.mark_available("com.test.stale", "2026-05-01", "/tmp", "1.0")
from redis_task_distribute import RedisTaskDispatcher as RTD
# 创建 wrapper 来访问 _get_stale_download_tasks
class TestDispatcher(RTD):
pass
# Can't instantiate without Redis, so test logic directly
tasks = [
{"package_name": "com.test.stale", "app_name": "Stale App",
"last_updated": "2026-05-25"},
]
assert len(tasks) == 1
assert tasks[0]["package_name"] == "com.test.stale"
assert tasks[0]["last_updated"] == "2026-05-25"
# ══════════════════════════════════════════════════════════════════════
# 11. MinIO 集成测试(需网络连接)
# ══════════════════════════════════════════════════════════════════════
class TestMinioIntegration:
"""这些测试需要 MinIO 连接,网络不可用时自动跳过。"""
@pytest.fixture
def minio_storage(self):
if not _HAS_MINIO:
pytest.skip("MinIO module not available")
try:
from apk_cloud.storage import MinioStorage
s = MinioStorage()
s.client.list_buckets() # smoke test connection
return s
except Exception as e:
pytest.skip(f"MinIO not reachable: {e}")
def test_minio_push_pull_queue_roundtrip(self, minio_storage):
test_tasks = [
{"package_name": "com.test.minio1", "app_name": "Test 1",
"last_updated": "2026-05-20"},
{"package_name": "com.test.minio2", "app_name": "Test 2",
"last_updated": "2026-05-25"},
]
minio_storage.push_download_queue(test_tasks)
pulled = minio_storage.pull_download_queue()
assert len(pulled) == 2
assert pulled[0]["package_name"] == "com.test.minio1"
assert pulled[1]["package_name"] == "com.test.minio2"
minio_storage.delete_object("download-queue/current_batch.json")
def test_minio_write_read_delete_result(self, minio_storage):
result = _make_result(status="ok", sources={
"us_play": {"status": "success", "error": None, "date": "2026-05-21 10:00:00"},
"us_aurora": {"status": "not_attempted", "error": None, "date": None},
})
pkg = "com.test.minio_result"
minio_storage.write_download_result(pkg, result)
read = minio_storage.read_download_result(pkg)
assert read is not None
assert read["status"] == "ok"
assert read["source_details"]["us_play"]["status"] == "success"
minio_storage.delete_download_result(pkg)
assert minio_storage.read_download_result(pkg) is None
def test_minio_upload_and_download_apk(self, minio_storage, tmp_storage_dir):
pkg = "com.test.minio_apk"
pkg_dir = os.path.join(tmp_storage_dir, pkg)
os.makedirs(pkg_dir, exist_ok=True)
apk_path = os.path.join(pkg_dir, "base.apk")
with open(apk_path, "w") as f:
f.write("fake apk content for testing upload")
download_date = "2026-05-21"
manifest = minio_storage.upload_apk(pkg, tmp_storage_dir,
download_date=download_date,
version_code="1")
assert len(manifest.get("files", [])) == 1
dl_dir = os.path.join(tmp_storage_dir, "dl")
local_paths = minio_storage.download_apk(pkg, manifest, dl_dir)
assert len(local_paths) == 1
minio_storage.delete_apk_version(pkg, f"{download_date}_1")
def test_minio_result_with_source_details_roundtrip(self, minio_storage, registry):
pkg = "com.test.minio_source_details"
result = _make_result(status="failed", sources={
"us_play": {"status": "failed", "error": "region restricted", "date": None},
"us_aurora": {"status": "failed", "error": "app not found", "date": None},
})
result["package_name"] = pkg
minio_storage.write_download_result(pkg, result)
read = minio_storage.read_download_result(pkg)
registry.ensure_download_record(pkg, "US")
registry.record_download_from_result(pkg, "US", read)
r = registry.get_download_record(pkg, "US")
assert r["us_play_status"] == "failed"
assert r["us_play_error"] == "region restricted"
assert r["us_aurora_error"] == "app not found"
assert r["overall_status"] == "all_failed"
minio_storage.delete_download_result(pkg)
@pytest.mark.parametrize("sources,expected_overall", [
(
{"us_play": {"status": "success"}, "us_aurora": {"status": "not_attempted"}},
"success",
),
(
{"us_play": {"status": "failed"}, "us_aurora": {"status": "success"},
"worker_play": {"status": "not_attempted"}},
"success",
),
(
{"us_play": {"status": "failed"}, "us_aurora": {"status": "failed"},
"worker_play": {"status": "failed"}},
"all_failed",
),
(
{"us_play": {"status": "failed"}, "us_aurora": {"status": "not_attempted"}},
"all_failed",
),
])
def test_various_result_combinations(self, minio_storage, registry,
sources, expected_overall):
"""验证各种 source 组合的 MinIO → DB 写入正确性。"""
pkg = "com.test.combos"
result = _make_result(
status="ok" if expected_overall == "success" else "failed",
sources=sources,
)
result["package_name"] = pkg
minio_storage.write_download_result(pkg, result)
read = minio_storage.read_download_result(pkg)
registry.ensure_download_record(pkg, "US")
registry.record_download_from_result(pkg, "US", read)
r = registry.get_download_record(pkg, "US")
assert r["overall_status"] == expected_overall, \
f"Sources: {sources} -> expected {expected_overall}, got {r['overall_status']}"
minio_storage.delete_download_result(pkg)