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

730 lines
26 KiB
Python

import argparse
import csv
import importlib.util
import json
from pathlib import Path
import pytest
from analytics import AnalyticsRepository
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = PROJECT_ROOT / "scripts" / "manage_app_catalog.py"
MODULE_SPEC = importlib.util.spec_from_file_location("manage_app_catalog", MODULE_PATH)
manage_app_catalog = importlib.util.module_from_spec(MODULE_SPEC)
assert MODULE_SPEC.loader is not None
MODULE_SPEC.loader.exec_module(manage_app_catalog)
@pytest.fixture
def db_path(tmp_path) -> str:
path = tmp_path / "monitoring.db"
AnalyticsRepository(db_path=str(path))
return str(path)
def _normalize_tags(value):
if value is None:
return []
if isinstance(value, str):
text = value.strip()
if not text:
return []
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
except (TypeError, ValueError):
pass
return [text]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
def _failure_parts(failure_type: str):
category, _, code_text = str(failure_type or "").partition("/")
try:
code = int(code_text)
except (TypeError, ValueError):
code = None
return category or None, code
def seed_catalog_rows(db_path: str, rows) -> None:
repo = AnalyticsRepository(db_path=db_path)
with repo._write_lock, repo._connect() as connection:
for index, row in enumerate(rows):
package_name = row["package_name"]
app_name = row.get("app_name", package_name)
tags = _normalize_tags(
row.get(
"batch_tags",
row.get("incremental_batch_tags", row.get("incremental_batch_tag", "")),
)
)
last_updated = row.get("last_updated", "")
country_code = row.get("country_code", "US")
device_type = row.get("device_type", "emulator")
app_magic_label = row.get("app_magic_label", "")
payload = row.get("task_payload") or {
"task_key": f"{app_name}_{package_name}",
"app_name": app_name,
"package_name": package_name,
"app_magic_label": app_magic_label,
"last_updated": last_updated,
"country_code": country_code,
"country_codes": [country_code] if country_code else [],
"device_type": device_type,
"available_sources": ["google_play", "local"],
"original_row": {
"app_name": app_name,
"package_name": package_name,
"app_magic_label": app_magic_label,
"last_updated": last_updated,
"country_code": country_code,
"device_type": device_type,
},
}
created_at = f"2026-01-01 00:00:{index:02d}"
connection.execute(
"""
INSERT INTO app_catalog (
package_name, app_name, batch_tags, app_magic_label,
last_updated, country_code, device_type, task_payload_json,
last_update_interval_days, source_order, is_active,
downloads, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
package_name,
app_name,
json.dumps(tags, ensure_ascii=False),
app_magic_label,
last_updated,
country_code,
device_type,
json.dumps(payload, ensure_ascii=False),
int(row.get("last_update_interval_days", 0) or 0),
row.get("source_order"),
int(row.get("catalog_active", row.get("is_active", 1))),
row.get("downloads", 0),
created_at,
created_at,
),
)
if row.get("create_task", True) is False:
continue
failure_type = row.get("latest_failure_type", "")
collection_status = row.get("collection_status")
latest_status = row.get("latest_status", "")
if collection_status is None:
if latest_status == "success":
collection_status = "qualified"
elif failure_type:
collection_status = "failed_terminal"
else:
collection_status = "pending"
task_status = "pending"
execution_status = None
completed_at = None
if latest_status or failure_type or collection_status in {"qualified", "failed_terminal"}:
task_status = "completed"
execution_status = latest_status or ("success" if collection_status == "qualified" else "failed")
completed_at = created_at
if execution_status == "success":
total_traffic_bytes = int(row.get("total_traffic_bytes", 100) or 0)
self_ratio = float(row.get("self_ratio", 10.0) or 0.0)
self_traffic_bytes = int(row.get("self_traffic_bytes", total_traffic_bytes * self_ratio / 100) or 0)
num_nodes = int(row.get("num_nodes", 12) or 0)
else:
total_traffic_bytes = int(row.get("total_traffic_bytes", 0) or 0)
self_traffic_bytes = int(row.get("self_traffic_bytes", 0) or 0)
num_nodes = int(row.get("num_nodes", 0) or 0)
error_category, error_code = _failure_parts(failure_type)
task_type = row.get("collection_task_type", "new_app")
connection.execute(
"""
INSERT INTO collection_task (
package_name, batch_tag, run_kind, attempt,
task_key, app_name, app_magic_label, is_new_app,
task_status, execution_status, worker_id,
created_at, completed_at,
error_category, error_code, error_reason, error_details,
num_nodes, total_traffic_bytes, self_traffic_bytes,
server_traffic_bytes, unrecognized_traffic_bytes
) VALUES (?, ?, 'ranking', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
package_name,
tags[-1] if tags else "seed",
int(row.get("attempt", 1) or 1),
f"{app_name}_{package_name}",
app_name,
app_magic_label,
1 if task_type == "new_app" else 0,
task_status,
execution_status,
row.get("worker_id", "seed-worker"),
created_at,
completed_at,
error_category,
error_code,
row.get("collection_status_reason", failure_type or collection_status or "seed"),
row.get("latest_task_detail", failure_type or ""),
num_nodes,
total_traffic_bytes,
self_traffic_bytes,
int(row.get("server_traffic_bytes", 0) or 0),
int(row.get("unrecognized_traffic_bytes", 0) or 0),
),
)
def fetch_summary_row(db_path: str, package_name: str) -> dict:
repo = AnalyticsRepository(db_path=db_path)
return repo.get_collection_row(package_name) or {}
def fetch_catalog_row(db_path: str, package_name: str) -> dict:
repo = AnalyticsRepository(db_path=db_path)
with repo._connect() as connection:
row = connection.execute(
"SELECT * FROM app_catalog WHERE package_name = ?",
(package_name,),
).fetchone()
return dict(row) if row else {}
def fetch_collection_tasks(db_path: str, package_name: str):
repo = AnalyticsRepository(db_path=db_path)
with repo._connect() as connection:
rows = connection.execute(
"""
SELECT *
FROM collection_task
WHERE package_name = ?
ORDER BY attempt ASC
""",
(package_name,),
).fetchall()
return [dict(row) for row in rows]
def count_pending_task_rows(db_path: str, package_name: str) -> int:
return sum(1 for row in fetch_collection_tasks(db_path, package_name) if row["task_status"] == "pending")
def write_catalog_csv(path: Path, rows) -> None:
normalized_rows = []
base_fields = ["app_name", "package_name", "last_updated", "country_code", "device_type"]
fieldnames = list(base_fields)
for item in rows:
if isinstance(item, dict):
row = dict(item)
row.setdefault("app_name", row["package_name"])
row.setdefault("last_updated", "")
row.setdefault("country_code", "US")
row.setdefault("device_type", "emulator")
else:
row = {
"app_name": item,
"package_name": item,
"last_updated": "",
"country_code": "US",
"device_type": "emulator",
}
normalized_rows.append(row)
for key in row:
if key not in fieldnames:
fieldnames.append(key)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(normalized_rows)
def test_sync_from_csv_persists_csv_payload_columns_and_creates_pending_task(db_path, tmp_path, capsys):
csv_path = tmp_path / "catalog.csv"
write_catalog_csv(
csv_path,
[
{
"app_name": "New App",
"package_name": "com.example.new",
"last_updated": "2026-04-15",
"country_code": "JP",
"device_type": "physical",
"app_magic_label": "finance",
"downloads": "1M",
"available_sources": "google_play,local",
"custom_column": "kept",
}
],
)
manage_app_catalog.sync_from_csv(
argparse.Namespace(
db_path=db_path,
csv_path=str(csv_path),
incremental_batch_tag="",
dry_run=False,
)
)
output = capsys.readouterr().out
assert "added=1" in output
assert "history_new=1" in output
assert "pending_tasks_created=1" in output
assert "payload_storage_issues=0" in output
catalog_row = fetch_catalog_row(db_path, "com.example.new")
assert catalog_row["last_updated"] == "2026/04/15"
assert catalog_row["country_code"] == "JP"
assert catalog_row["device_type"] == "physical"
payload = json.loads(catalog_row["task_payload_json"])
assert payload["last_updated"] == "2026/04/15"
assert payload["country_code"] == "JP"
assert payload["device_type"] == "physical"
assert payload["available_sources"] == ["google_play", "local"]
assert payload["original_row"]["custom_column"] == "kept"
assert payload["original_row"]["last_updated"] == "2026/04/15"
tasks = fetch_collection_tasks(db_path, "com.example.new")
assert len(tasks) == 1
assert tasks[0]["task_status"] == "pending"
assert tasks[0]["is_new_app"] == 1
def test_sync_from_csv_counts_added_against_full_history(db_path, tmp_path, capsys):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.active",
"source_order": 0,
"catalog_active": 1,
"collection_status": "qualified",
"latest_status": "success",
},
{
"package_name": "com.example.reactivated",
"source_order": 1,
"catalog_active": 0,
"collection_status": "qualified",
"latest_status": "success",
},
],
)
csv_path = tmp_path / "catalog.csv"
write_catalog_csv(
csv_path,
["com.example.active", "com.example.reactivated", "com.example.new"],
)
manage_app_catalog.sync_from_csv(
argparse.Namespace(
db_path=db_path,
csv_path=str(csv_path),
incremental_batch_tag="batch-new",
dry_run=False,
)
)
output = capsys.readouterr().out
assert "added=1" in output
assert "history_new=1" in output
assert "reactivated=1" in output
assert "pending_tasks_created=1" in output
assert "payload_storage_issues=0" in output
assert "incremental_marked=3" in output
assert "batch-new" in fetch_summary_row(db_path, "com.example.new")["incremental_batch_tags"]
assert "batch-new" in fetch_summary_row(db_path, "com.example.reactivated")["incremental_batch_tags"]
assert fetch_summary_row(db_path, "com.example.reactivated")["catalog_active"] is True
def test_sync_from_csv_dry_run_counts_added_against_full_history(db_path, tmp_path, capsys):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.reactivated",
"source_order": 1,
"catalog_active": 0,
"collection_status": "qualified",
"latest_status": "success",
},
],
)
csv_path = tmp_path / "catalog.csv"
write_catalog_csv(csv_path, ["com.example.reactivated", "com.example.new"])
manage_app_catalog.sync_from_csv(
argparse.Namespace(
db_path=db_path,
csv_path=str(csv_path),
incremental_batch_tag="batch-new",
dry_run=True,
)
)
output = capsys.readouterr().out
assert "added=1" in output
assert "history_new=1" in output
assert "reactivated=1" in output
assert "would_create_pending_tasks=1" in output
assert "incremental_marked=2" in output
assert fetch_summary_row(db_path, "com.example.reactivated")["catalog_active"] is False
def test_sync_from_csv_dry_run_counts_version_updates_against_full_history(db_path, tmp_path, capsys):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.reactivated_update",
"source_order": 1,
"catalog_active": 0,
"collection_status": "qualified",
"latest_status": "success",
"last_updated": "2026/04/01",
},
],
)
csv_path = tmp_path / "catalog.csv"
write_catalog_csv(
csv_path,
[{"package_name": "com.example.reactivated_update", "last_updated": "2026/04/15"}],
)
manage_app_catalog.sync_from_csv(
argparse.Namespace(
db_path=db_path,
csv_path=str(csv_path),
incremental_batch_tag="batch-new",
dry_run=True,
)
)
output = capsys.readouterr().out
assert "added=0" in output
assert "history_new=0" in output
assert "reactivated=1" in output
assert "version_updates=1" in output
assert "would_create_pending_tasks=1" in output
assert fetch_summary_row(db_path, "com.example.reactivated_update")["catalog_active"] is False
def test_sync_from_csv_marks_reactivated_history_version_update_as_app_update(db_path, tmp_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.reactivated_update",
"source_order": 1,
"catalog_active": 0,
"collection_status": "qualified",
"latest_status": "success",
"last_updated": "2026/04/01",
"collection_task_type": "new_app",
},
],
)
csv_path = tmp_path / "catalog.csv"
write_catalog_csv(
csv_path,
[{"package_name": "com.example.reactivated_update", "last_updated": "2026/04/15"}],
)
manage_app_catalog.sync_from_csv(
argparse.Namespace(
db_path=db_path,
csv_path=str(csv_path),
incremental_batch_tag="batch-new",
dry_run=False,
)
)
row = fetch_summary_row(db_path, "com.example.reactivated_update")
assert row["catalog_active"] is True
assert row["collection_task_type"] == "app_update"
assert row["collection_status"] == "pending"
assert row["collection_status_reason"] == "catalog_version_update:2026/04/01->2026/04/15"
assert row["last_updated"] == "2026/04/15"
assert row["last_update_interval_days"] > 0
tasks = fetch_collection_tasks(db_path, "com.example.reactivated_update")
assert len(tasks) == 2
assert tasks[-1]["task_status"] == "pending"
assert tasks[-1]["is_new_app"] == 0
@pytest.mark.parametrize(
"argv",
[
["set-pending-by-error-type", "APP_ERROR/1", "--tag", "batch-a", "--top-n", "10"],
["set-pending-success-zero-self-ratio", "--tag", "batch-a", "--top-n", "10"],
["set-pending-retryable", "--tag", "batch-a", "--top-n", "10"],
["set-existing-non-retryable-light-restricted", "--tag", "batch-a", "--top-n", "10"],
["export-by-error-type", "APP_ERROR/1", "--tag", "batch-a", "--top-n", "10", "--output", "/tmp/out.csv"],
],
)
def test_build_parser_accepts_scope_filters(argv):
parser = manage_app_catalog.build_parser()
args = parser.parse_args(["--db-path", "/tmp/test.db", *argv])
assert getattr(args, "tag", "") == "batch-a"
assert getattr(args, "top_n", None) == 10
def test_set_pending_by_error_type_respects_tag_and_top_n(db_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.target",
"source_order": 1,
"incremental_batch_tag": "batch-a",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
},
{
"package_name": "com.example.out_of_topn",
"source_order": 5,
"incremental_batch_tag": "batch-a",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
},
{
"package_name": "com.example.other_tag",
"source_order": 0,
"incremental_batch_tag": "batch-b",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
},
],
)
manage_app_catalog.set_pending_by_error_type(
argparse.Namespace(
db_path=db_path,
error_types=["APP_ERROR/1"],
error_type_prefix="",
all_non_retryable=False,
non_retryable_only=False,
pending_only=False,
reason="manual_test",
device_type="",
tag="batch-a",
top_n=3,
)
)
assert fetch_summary_row(db_path, "com.example.target")["collection_status"] == "pending"
assert count_pending_task_rows(db_path, "com.example.target") == 1
assert fetch_summary_row(db_path, "com.example.out_of_topn")["collection_status"] == "failed_terminal"
assert fetch_summary_row(db_path, "com.example.other_tag")["collection_status"] == "failed_terminal"
def test_set_pending_success_zero_self_ratio_respects_tag_and_top_n(db_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.target",
"source_order": 1,
"incremental_batch_tag": "batch-a",
"collection_status": "qualified",
"latest_status": "success",
"self_ratio": 0.0,
"num_nodes": 0,
},
{
"package_name": "com.example.out_of_topn",
"source_order": 5,
"incremental_batch_tag": "batch-a",
"collection_status": "qualified",
"latest_status": "success",
"self_ratio": 0.0,
"num_nodes": 0,
},
{
"package_name": "com.example.other_tag",
"source_order": 0,
"incremental_batch_tag": "batch-b",
"collection_status": "qualified",
"latest_status": "success",
"self_ratio": 0.0,
"num_nodes": 0,
},
],
)
manage_app_catalog.set_pending_success_zero_self_ratio(
argparse.Namespace(
db_path=db_path,
max_self_ratio=0.0,
reason="manual_test",
tag="batch-a",
top_n=3,
)
)
assert fetch_summary_row(db_path, "com.example.target")["collection_status"] == "pending"
assert count_pending_task_rows(db_path, "com.example.target") == 1
assert fetch_summary_row(db_path, "com.example.out_of_topn")["collection_status"] == "qualified"
assert fetch_summary_row(db_path, "com.example.other_tag")["collection_status"] == "qualified"
def test_set_pending_retryable_respects_tag_and_top_n(db_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.target",
"source_order": 1,
"incremental_batch_tag": "batch-a",
"collection_status": "pending",
"latest_status": "failed",
"latest_failure_type": "DOWNLOAD_ERROR/5",
},
{
"package_name": "com.example.out_of_topn",
"source_order": 5,
"incremental_batch_tag": "batch-a",
"collection_status": "pending",
"latest_status": "failed",
"latest_failure_type": "DOWNLOAD_ERROR/5",
},
{
"package_name": "com.example.other_tag",
"source_order": 0,
"incremental_batch_tag": "batch-b",
"collection_status": "pending",
"latest_status": "failed",
"latest_failure_type": "DOWNLOAD_ERROR/5",
},
],
)
assert count_pending_task_rows(db_path, "com.example.target") == 0
manage_app_catalog.set_pending_retryable(
argparse.Namespace(
db_path=db_path,
current_status="pending",
reason="manual_test",
tag="batch-a",
top_n=3,
)
)
assert fetch_summary_row(db_path, "com.example.target")["collection_status"] == "pending"
assert count_pending_task_rows(db_path, "com.example.target") == 1
assert count_pending_task_rows(db_path, "com.example.out_of_topn") == 0
assert count_pending_task_rows(db_path, "com.example.other_tag") == 0
def test_set_existing_non_retryable_light_restricted_respects_tag_and_top_n(db_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.target",
"source_order": 1,
"incremental_batch_tag": "batch-a",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
"num_nodes": 0,
},
{
"package_name": "com.example.out_of_topn",
"source_order": 5,
"incremental_batch_tag": "batch-a",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
"num_nodes": 0,
},
{
"package_name": "com.example.other_tag",
"source_order": 0,
"incremental_batch_tag": "batch-b",
"collection_status": "failed_terminal",
"latest_failure_type": "APP_ERROR/1",
"num_nodes": 0,
},
],
)
manage_app_catalog.set_existing_non_retryable_light_restricted(
argparse.Namespace(
db_path=db_path,
min_num_nodes=0,
reason="manual_test",
tag="batch-a",
top_n=3,
)
)
assert fetch_summary_row(db_path, "com.example.target")["collection_status"] == "pending"
assert count_pending_task_rows(db_path, "com.example.target") == 1
assert fetch_summary_row(db_path, "com.example.out_of_topn")["collection_status"] == "failed_terminal"
assert fetch_summary_row(db_path, "com.example.other_tag")["collection_status"] == "failed_terminal"
def test_export_apps_by_error_type_respects_tag_and_top_n(db_path, tmp_path):
seed_catalog_rows(
db_path,
[
{
"package_name": "com.example.target",
"source_order": 1,
"incremental_batch_tag": "batch-a",
"latest_failure_type": "APP_ERROR/1",
"latest_task_detail": "target detail",
"downloads": 123,
},
{
"package_name": "com.example.out_of_topn",
"source_order": 5,
"incremental_batch_tag": "batch-a",
"latest_failure_type": "APP_ERROR/1",
"latest_task_detail": "out of topn detail",
"downloads": 456,
},
{
"package_name": "com.example.other_tag",
"source_order": 0,
"incremental_batch_tag": "batch-b",
"latest_failure_type": "APP_ERROR/1",
"latest_task_detail": "other tag detail",
"downloads": 789,
},
],
)
output_path = tmp_path / "export.csv"
manage_app_catalog.export_apps_by_error_type(
argparse.Namespace(
db_path=db_path,
error_types=["APP_ERROR/1"],
error_type_prefix="",
output=str(output_path),
non_retryable_only=False,
tag="batch-a",
top_n=3,
)
)
with output_path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
assert [row["package_name"] for row in rows] == ["com.example.target"]