967 lines
46 KiB
Python
967 lines
46 KiB
Python
"""
|
||
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)
|