chore: [workflow] 提交工作区代码

This commit is contained in:
wangyunlong committed 2026-09-30 10:46:15 +08:00
1 parent 2ff588a65d
commit 01f3d7aaf7
131 files changed
+24306 -1320

No files matched your search

@@ -161,7 +161,7 @@ def test_erp_login_navigates_before_probing_initial_blank_page(
action = erp_login.make_page_action(
login_url="https://erp.test/login.aspx",
username="user",
password="pw",
password="secret",
home_url="https://erp.test/epaas",
timeout_ms=1000,
captcha_checks=0,
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
import collect_douyin_qianchuan_ads as collector
@@ -90,3 +91,128 @@ def test_report_ready_wait_guards_missing_body() -> None:
"arg": list(collector._required_page_markers()),
"timeout": 90_000,
}
def test_matching_task_rows_uses_scene_and_exact_date_range() -> None:
filters: list[str] = []
class _Locator:
def filter(self, *, has_text: str):
filters.append(has_text)
return self
class _Page:
def locator(self, selector: str) -> _Locator:
assert selector == "tr.ovui-tr"
return _Locator()
collector._matching_task_rows(
_Page(), date(2026, 9, 9), date(2026, 9, 9)
)
assert filters == [
collector.TASK_SCENE_MARKER,
"2026-09-09 00:00:00-2026-09-09 23:59:59",
]
def test_trigger_report_download_returns_direct_download() -> None:
expected_download = object()
class _Button:
def __init__(self, page) -> None:
self.page = page
def click(self) -> None:
self.page.listener(expected_download)
class _Page:
listener = None
def on(self, event: str, listener) -> None:
assert event == "download"
self.listener = listener
def remove_listener(self, event: str, listener) -> None:
assert event == "download"
assert listener == self.listener
def locator(self, selector: str):
assert selector == collector.DOWNLOAD_SELECTOR
return _Button(self)
assert collector._trigger_report_download(_Page()) is expected_download
def test_trigger_report_download_detects_async_task_notification() -> None:
class _Locator:
def click(self) -> None:
pass
def inner_text(self, *, timeout: int) -> str:
assert timeout == 10_000
return "已生成下载任务,可前往任务中心查看"
class _Page:
def on(self, event: str, listener) -> None:
assert event == "download"
def remove_listener(self, event: str, listener) -> None:
assert event == "download"
def locator(self, selector: str) -> _Locator:
assert selector in {collector.DOWNLOAD_SELECTOR, "body"}
return _Locator()
assert collector._trigger_report_download(_Page()) is None
def test_download_task_from_page_downloads_completed_matching_row() -> None:
expected_download = object()
class _DownloadInfo:
value = expected_download
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
pass
class _DownloadButton:
def click(self) -> None:
pass
class _Row:
def inner_text(self) -> str:
return "全域推广数据-推商品\n生成完成\n下载"
def get_by_text(self, text: str, *, exact: bool) -> _DownloadButton:
assert (text, exact) == ("下载", True)
return _DownloadButton()
class _Rows:
first = _Row()
def count(self) -> int:
return 1
class _Page:
def expect_download(self, *, timeout: int) -> _DownloadInfo:
assert timeout == 60_000
return _DownloadInfo()
page = _Page()
original = collector._matching_task_rows
collector._matching_task_rows = lambda *args: _Rows()
try:
actual = collector._download_task_from_page(
page,
date(2026, 9, 9),
date(2026, 9, 9),
wait_timeout_ms=0,
)
finally:
collector._matching_task_rows = original
assert actual is expected_download
@@ -173,3 +173,173 @@ def test_safe_extract_zip_keeps_csv_inside_output(tmp_path: Path) -> None:
files = collector._safe_extract_zip(archive, tmp_path / "extracted")
assert files == [(tmp_path / "extracted" / "nested" / "full.csv").resolve()]
FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-19至2026-09-19
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260919~20260919,当前时间,10032366483452,波塞冬托特包,img,43.68
20260919~20260919,当前时间,10033333485894,宙斯双肩包,img,104.78
汇总
0,当前时间,0,,,148.46
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260918~20260918,对比时间,10032366483452,波塞冬托特包,img,40.00
汇总
0,对比时间,0,,,40.00
"""
NOT_READY_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
汇总
0,当前时间,0,,,0.00,0.00,0.00,0,0.00
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260919~20260919,对比时间,10032366483452,波塞冬托特包,img,43.68
汇总
0,对比时间,0,,,148.46
"""
NON_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
SKU ID,SKU 名称,点击时间,'当前时间',花费
10144206623346,阿波罗斜挎包,20260920~20260920,当前时间,105.50
汇总
0,当前时间,0,,,105.50
"""
def _write_report(tmp_path: Path, name: str, content: str) -> Path:
path = tmp_path / name
path.write_text(content, encoding="utf-8-sig")
return path
def test_report_content_rows_counts_real_current_time_spu_rows(tmp_path: Path) -> None:
path = _write_report(tmp_path, "full_20260919.csv", FULL_REPORT_CSV)
assert collector._report_content_rows(path, "full") == 2
def test_report_content_rows_flags_report_generated_before_data_ready(
tmp_path: Path,
) -> None:
path = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
assert collector._report_content_rows(path, "full") == 0
def test_report_content_rows_counts_non_full_sku_rows(tmp_path: Path) -> None:
path = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
assert collector._report_content_rows(path, "non_full") == 1
def test_report_content_rows_rejects_csv_without_marker_header(tmp_path: Path) -> None:
path = _write_report(tmp_path, "broken.csv", "查询条件\n花费\n1.00\n")
with pytest.raises(collector.JDAdCostCollectionError, match="表头"):
collector._report_content_rows(path, "full")
def test_ensure_report_data_accepts_empty_full_when_non_full_has_data(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
resubmits: list[collector.ReportType] = []
sleeps: list[float] = []
monkeypatch.setattr(
collector,
"_report_content_rows",
lambda path, rt: 0 if rt == "full" else 1,
)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: resubmits.append(rt),
)
monkeypatch.setattr(collector.time, "sleep", sleeps.append)
downloaded = collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert downloaded == {"non_full": sku_report, "full": empty_full}
assert resubmits == []
assert sleeps == []
def test_ensure_report_data_resubmits_both_reports_when_neither_has_data(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
actions: list[object] = []
row_counts = iter((0, 0, 1, 2))
monkeypatch.setattr(
collector, "_report_content_rows", lambda _path, _rt: next(row_counts)
)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: actions.append(("resubmit", rt)),
)
def fake_download(_page, rt, _td, _od, *, timeout_seconds):
del timeout_seconds
actions.append(("download", rt))
return {"non_full": sku_report, "full": empty_full}[rt]
monkeypatch.setattr(collector, "download_ready_report", fake_download)
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
downloaded = collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert downloaded == {"non_full": sku_report, "full": empty_full}
assert actions == [
("resubmit", "non_full"),
("download", "non_full"),
("resubmit", "full"),
("download", "full"),
]
def test_ensure_report_data_fails_when_non_full_never_arrives(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
resubmits: list[collector.ReportType] = []
monkeypatch.setattr(collector, "_report_content_rows", lambda _path, _rt: 0)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: resubmits.append(rt),
)
monkeypatch.setattr(
collector, "download_ready_report", lambda _page, rt, _td, _od, *, timeout_seconds: {"non_full": sku_report, "full": empty_full}[rt]
)
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
with pytest.raises(collector.JDAdCostCollectionError, match="尚未就绪"):
collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert resubmits == ["non_full", "full", "non_full", "full"]
@@ -1,5 +1,6 @@
from __future__ import annotations
import io
import json
import zipfile
from datetime import date
@@ -171,6 +172,75 @@ def test_safe_extract_zip_returns_csv_and_writes_manifest(tmp_path: Path) -> Non
assert (tmp_path / "extracted" / "商品报表_20260817.csv").is_file()
def test_save_signed_download_retries_transient_dns_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
class _Response:
ok = True
status = 200
def body(self) -> bytes:
payload = io.BytesIO()
with zipfile.ZipFile(payload, "w") as archive:
archive.writestr("商品报表.csv", "日期,主体ID\n")
return payload.getvalue()
class _Request:
def __init__(self) -> None:
self.calls = 0
def get(self, url: str, *, timeout: int) -> _Response:
del timeout
self.calls += 1
if self.calls == 1:
raise RuntimeError(
f"APIRequestContext.get: getaddrinfo ENOTFOUND {url}"
)
return _Response()
request = _Request()
class _Page:
context = type("_Context", (), {"request": request})()
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
download_url = "https://example.test/report.zip?Signature=secret"
path = collector._save_signed_download(
_Page(), download_url, tmp_path, date(2026, 8, 17)
)
assert request.calls == 2
assert zipfile.is_zipfile(path)
def test_save_signed_download_redacts_signed_url_after_network_retries(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
class _Request:
def get(self, url: str, *, timeout: int) -> object:
del timeout
raise RuntimeError(f"getaddrinfo ENOTFOUND {url}")
class _Page:
context = type(
"_Context", (), {"request": _Request()}
)()
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
download_url = "https://example.test/report.zip?Signature=secret"
with pytest.raises(collector.WanxiangDownloadError) as exc_info:
collector._save_signed_download(
_Page(), download_url, tmp_path, date(2026, 8, 17)
)
message = str(exc_info.value)
assert "getaddrinfo ENOTFOUND" in message
assert "Signature=secret" not in message
assert "https://example.test/report.zip" in message
def test_safe_extract_zip_rejects_path_traversal(tmp_path: Path) -> None:
archive_path = tmp_path / "unsafe.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
@@ -120,7 +120,7 @@ def test_get_pool_retries_transient_initial_connection(
"PG_PORT": "5432",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "unit-test-only",
"PG_PASSWORD": "secret",
}.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(db, "_pool", None)
@@ -213,3 +213,64 @@ def test_jd_dry_run_accepts_explicit_desktop_style_files(tmp_path: Path, capsys)
assert "全站 SPU 1" in output
assert "非全站 SKU 1" in output
assert "DRY-RUN" in output
def test_parse_files_treats_empty_full_report_as_zero_when_sku_has_rows(
tmp_path: Path,
capsys,
) -> None:
full = tmp_path / "full_20260920.csv"
full.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
"汇总\n"
"0,当前时间,0,,,0.00,0.00\n",
encoding="utf-8-sig",
)
sku = tmp_path / "non_full_20260920.csv"
sku.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
"sku-1,光影行星 盖亚款式 黑色,20260920~20260920,当前时间,3.20,100,5,2.50\n",
encoding="utf-8-sig",
)
full_records, sku_records = importer._parse_files(
date(2026, 9, 20),
full_file=full,
non_full_file=sku,
advertiser_pin="光影行星旗舰店霖",
shop_account="光影行星研发",
)
assert full_records == []
assert [record["sku_id"] for record in sku_records] == ["sku-1"]
assert "零投放" in capsys.readouterr().out
def test_parse_files_still_fails_when_both_reports_are_empty(tmp_path: Path) -> None:
empty = tmp_path / "full_20260920.csv"
empty.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
"0,当前时间,0,,,0.00,0.00\n",
encoding="utf-8-sig",
)
empty_sku = tmp_path / "non_full_20260920.csv"
empty_sku.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
"0,汇总占位,20260920~20260920,当前时间,0.00,0,0,0.00\n",
encoding="utf-8-sig",
)
import pytest
with pytest.raises(ValueError, match="当前时间 SKU 行"):
importer._parse_files(
date(2026, 9, 20),
full_file=empty,
non_full_file=empty_sku,
advertiser_pin="光影行星旗舰店霖",
shop_account="光影行星研发",
)
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from datetime import datetime
from jd_main_image_collector import (
DETAIL_URL,
_goto,
_load_reusable_style_output,
_locate_product_detail_search,
_wait_for_product_detail,
)
class _Locator:
def __init__(self, visible: bool) -> None:
self.visible = visible
@property
def first(self) -> _Locator:
return self
def is_visible(self, timeout: int = 0) -> bool:
return self.visible
class _Page:
def __init__(self, visible_selectors: set[str], goto_errors: int = 0) -> None:
self.url = DETAIL_URL
self.visible_selectors = visible_selectors
self.goto_errors = goto_errors
self.waits: list[str] = []
self.timeouts: list[int] = []
def locator(self, selector: str) -> _Locator:
return _Locator(selector in self.visible_selectors)
def wait_for_timeout(self, milliseconds: int) -> None:
self.timeouts.append(milliseconds)
def goto(self, url: str, **kwargs: object) -> None:
self.url = url
self.waits.append(str(kwargs["wait_until"]))
if self.goto_errors:
self.goto_errors -= 1
raise RuntimeError("net::ERR_CONNECTION_CLOSED")
def test_product_detail_search_uses_new_jd_input() -> None:
page = _Page({"input.jd-input-multiple-input-input"})
assert _locate_product_detail_search(page) is not None
def test_product_detail_wait_accepts_legacy_wrapper_fallback() -> None:
page = _Page({".jmtd-multiple-input"})
_wait_for_product_detail(page, timeout_ms=1_000)
def test_shangzhi_navigation_uses_domcontentloaded() -> None:
page = _Page(set())
_goto(page, DETAIL_URL)
assert page.waits == ["domcontentloaded"]
def test_product_detail_navigation_uses_new_jd_host() -> None:
assert DETAIL_URL == "https://jdsz.jd.com/szweb/view/product/productDetail.html"
def test_transient_detail_navigation_is_retried() -> None:
page = _Page(set(), goto_errors=1)
_goto(page, DETAIL_URL)
assert page.waits == ["domcontentloaded", "domcontentloaded"]
assert page.timeouts == [1000]
def test_same_date_result_is_reused_only_with_local_image(tmp_path) -> None:
style_dir = tmp_path / datetime.now().strftime("%Y-%m-%d") / "同款"
style_dir.mkdir(parents=True)
image_path = style_dir / "jd_images" / "main.jpg"
image_path.parent.mkdir()
image_path.write_bytes(b"image")
(style_dir / "jd_main_images.json").write_text(
json.dumps({"records": [{"image_key": "main.jpg", "local_image_path": str(image_path)}]}),
encoding="utf-8",
)
reusable = _load_reusable_style_output(tmp_path, "同款")
assert reusable is not None
assert reusable["records"][0]["image_key"] == "main.jpg"
@@ -34,6 +34,34 @@ class MainImagePathTests(unittest.TestCase):
self.assertEqual(resolve_images_dir(style_dir, "jd"), current)
class MainImageLarkRetryTests(unittest.TestCase):
def test_transient_feishu_transport_error_is_retried(self):
with (
patch.object(
tmall_insert,
"run_lark",
side_effect=[(1, "", "EOF"), (0, "ok", "")],
) as run_lark,
patch.object(tmall_insert.time, "sleep") as sleep,
):
result = tmall_insert.run_lark_with_retry(["base", "+field-list"])
self.assertEqual(result, (0, "ok", ""))
self.assertEqual(run_lark.call_count, 2)
sleep.assert_called_once_with(1.0)
def test_non_transient_feishu_error_is_not_retried(self):
with patch.object(
tmall_insert,
"run_lark",
return_value=(4, "", "missing field"),
) as run_lark:
result = tmall_insert.run_lark_with_retry(["base", "+field-list"])
self.assertEqual(result, (4, "", "missing field"))
run_lark.assert_called_once()
class MainImageWorkflowPreflightTests(unittest.TestCase):
def test_zero_valid_styles_is_a_formal_failure(self):
for runner in (tmall_runner, jd_runner):
@@ -8,6 +8,7 @@ from gyxx_flow.modules.product_commerce.market_rank_product_import import (
extract_document_labels,
normalize_scene_name,
normalize_style_name,
reuse_saved_categories,
)
RUNTIME_ROOT = (
@@ -87,6 +88,28 @@ def test_missing_tmall_price_keeps_product_but_excludes_it_from_amount():
assert snapshot["quality_status"] == "missing_price"
def test_saved_category_is_reused_by_platform_product_id_without_changing_raw_data():
snapshots = [
{
"platform_product_id": "123",
"category_name": "旅行箱",
"raw_data": {"category_name": "旅行箱", "rank": 1},
},
{
"platform_product_id": "456",
"category_name": "双肩包",
"raw_data": {"category_name": "双肩包", "rank": 2},
},
]
reused, count = reuse_saved_categories(snapshots, {"123": "商务旅行箱"})
assert reused[0]["category_name"] == "商务旅行箱"
assert reused[0]["raw_data"] == {"category_name": "旅行箱", "rank": 1}
assert reused[1]["category_name"] == "双肩包"
assert count == 1
def test_feishu_document_labels_keep_raw_values_and_normalized_values():
content = """
<title>京东榜单</title>
@@ -111,6 +134,7 @@ def test_feishu_document_labels_keep_raw_values_and_normalized_values():
assert labels["123456"]["style_name"] == "都市新旅"
assert labels["123456"]["raw_scene_name"] == "上班通勤"
assert labels["123456"]["scene_name"] == "通勤上班"
assert labels["123456"]["category_name"] == "旅行箱"
assert labels["123456"]["unit_price_text"] == "¥399"
@@ -121,3 +145,4 @@ def test_collector_schema_contains_idempotent_market_snapshot_tables():
assert "CREATE TABLE IF NOT EXISTS fact_market_product_snapshot" in schema
assert "CREATE TABLE IF NOT EXISTS market_product_classification" in schema
assert "UNIQUE (report_id, market_product_id, category_name)" in schema
assert "ADD COLUMN IF NOT EXISTS category_name TEXT" in schema
@@ -11,6 +11,7 @@ import collect_jd_persona_to_bitable as jd_worker
import collect_persona_to_bitable as tm_worker
import dy_audience_profile_collect as dy_collector
import pytest
import run_daily_persona as persona_launcher
import taobao_dmp_item_crowd_insight_screenshots as dmp
from gyxx_flow.adapters import acceptance_policy
@@ -315,6 +316,15 @@ def test_dy_persistent_profile_is_not_overwritten_by_exported_cookies() -> None:
}
def test_dy_account_profile_receives_vault_cookies_without_sharing_profile() -> None:
cookies = [{"name": "session", "value": "new", "domain": ".jinritemai.com"}]
assert dy_collector.account_profile_launch_options("bound-profile", cookies) == {
"user_data_dir": "bound-profile",
"cookies": cookies,
}
def test_dy_profileless_fallback_can_restore_exported_cookies() -> None:
cookies = [{"name": "session", "value": "new", "domain": ".jinritemai.com"}]
@@ -397,3 +407,48 @@ def test_jd_persona_login_home_navigation_does_not_wait_for_networkidle() -> Non
assert 'wait_until="networkidle"' not in home_block[:400]
assert 'wait_until="domcontentloaded"' in home_block[:400]
assert "timeout=60_000" in home_block[:400]
@pytest.mark.parametrize(
("url", "expected"),
(
(
"https://sz.jd.com/szweb/sz/view/productAnalysis/productDetail.html",
True,
),
(
"https://jdsz.jd.com/szweb/view/product/productDetail.html",
True,
),
("https://passport.jd.com/new/login.aspx?ReturnUrl=productDetail", False),
("https://sz.jd.com/sz/view/notAuthorized.html", False),
("https://example.com/szweb/view/product/productDetail.html", False),
),
)
def test_jd_product_detail_route_accepts_vendor_redirect_only(
url: str,
expected: bool,
) -> None:
assert jd_worker._is_jd_product_detail_url(url) is expected
def test_persona_launcher_can_select_failed_platforms_and_styles() -> None:
assert persona_launcher.selected_platforms("dy,jd,dy") == ["dy", "jd"]
assert persona_launcher.selected_platforms("") == ["tm", "dy", "jd"]
assert persona_launcher.build_child_command(
"collector.py",
BUSINESS_DATE,
"布谷,白鹭,蓝鹊",
) == [
sys.executable,
"collector.py",
"--date",
"2026-08-04",
"--styles",
"布谷,白鹭,蓝鹊",
]
def test_persona_launcher_rejects_unknown_platform() -> None:
with pytest.raises(ValueError, match="不支持的平台"):
persona_launcher.selected_platforms("tm,unknown")
@@ -98,6 +98,26 @@ def test_persona_workers_rebind_their_nested_browser_collectors(monkeypatch) ->
_assert_child_binding(observed["environment"])
def test_dy_persona_default_uses_inner_account_profile(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
dy_persona_worker,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["command"] = command
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(dy_persona_worker.subprocess, "run", fake_run)
assert dy_persona_worker.run_collect_all("", False, date(2026, 8, 4)) is True
assert "--user-data-dir" not in observed["command"]
_assert_child_binding(observed["environment"])
def test_daily_run_step_rebinds_to_command_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(daily, "environment_for_child_script", _fake_rebind(observed))
@@ -592,6 +592,44 @@ class FeishuDocumentWriteTests(unittest.TestCase):
self.assertEqual(doc_id, "doc_token")
run.assert_called_once()
def test_ambiguous_doc_create_reconciles_and_retries_after_missing_user_error(self):
created = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "granted", "perm": "full_access"},
}
responses = [
RuntimeError(
'lark-cli failed (exit=1): {"error":{"code":20008,'
'"message":"The user does not exist."}}'
),
{"ok": True, "data": {"results": []}},
{"identities": {"user": {"openId": "ou_current_user"}}},
created,
]
with (
patch.object(analysis, "run_lark_cli", side_effect=responses) as run,
patch.object(analysis, "DOC_CREATE_RECONCILE_DELAYS_SECONDS", (0,)),
):
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
self.assertEqual((url, doc_id), (
"https://example.feishu.cn/docx/doc_token",
"doc_token",
))
self.assertEqual(
[call.args[0][:2] for call in run.call_args_list],
[
["docs", "+create"],
["docs", "+search"],
["auth", "status"],
["docs", "+create"],
],
)
def test_missing_current_user_identity_stops_before_base_write(self):
responses = [
{"data": {"document": {
@@ -720,20 +758,39 @@ class ExternalEffectCheckpointTests(unittest.TestCase):
"permission_granted",
)
def test_ambiguous_and_corrupt_checkpoints_fail_closed(self):
def test_unconfirmed_checkpoint_reconciles_before_retrying_and_corrupt_fails_closed(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
ambiguous = self.checkpoint_spec()
state, owns_creation = analysis.claim_style_analysis_checkpoint(ambiguous)
self.assertTrue(owns_creation)
self.assertEqual(state["phase"], "doc_creating")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"doc_creating",
):
analysis.write_feishu_doc(ambiguous.title, "# 内容", ambiguous)
run.assert_not_called()
responses = [
{"ok": True, "data": {"results": []}},
{"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}}, "permission_grant": {"status": "granted"}},
]
with (
patch.object(analysis, "run_lark_cli", side_effect=responses) as run,
patch.object(analysis, "DOC_CREATE_RECONCILE_DELAYS_SECONDS", (0,)),
):
url, doc_id = analysis.write_feishu_doc(
ambiguous.title,
"# 内容",
ambiguous,
)
self.assertEqual(doc_id, "doc_token")
self.assertTrue(url.endswith("/docx/doc_token"))
self.assertEqual(
analysis.load_style_analysis_checkpoint(ambiguous)["phase"],
"permission_granted",
)
self.assertEqual(
[call.args[0][:2] for call in run.call_args_list],
[["docs", "+search"], ["docs", "+create"]],
)
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp) / "corrupt"):
corrupt = self.checkpoint_spec()
@@ -21,6 +21,78 @@ def _entry() -> baibu.BaibuEntry:
)
def test_export_template_retries_transient_network_failure(
monkeypatch,
tmp_path: Path,
) -> None:
calls: list[object] = []
sleeps: list[int] = []
def fake_run(_command, **_kwargs):
calls.append(_command)
if len(calls) == 2:
(tmp_path / "demo.xlsx").write_bytes(b"xlsx")
return SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr="")
return SimpleNamespace(
returncode=4,
stdout=(
'{"ok":false,"error":{"type":"network",'
'"subtype":"timeout"}}'
),
stderr="",
)
monkeypatch.setattr(baibu, "_lark_cli", lambda: "lark-cli")
monkeypatch.setattr(baibu.subprocess, "run", fake_run)
monkeypatch.setattr(baibu.time, "sleep", sleeps.append)
result = baibu._export_template(_entry(), tmp_path)
assert result == tmp_path / "demo.xlsx"
assert len(calls) == 2
assert sleeps == [5]
def test_export_template_does_not_retry_non_transient_failure(
monkeypatch,
tmp_path: Path,
) -> None:
calls: list[object] = []
sleeps: list[int] = []
def fake_run(_command, **_kwargs):
calls.append(_command)
return SimpleNamespace(
returncode=4,
stdout=(
'{"ok":false,"error":{"type":"permission",'
'"message":"forbidden"}}'
),
stderr="",
)
monkeypatch.setattr(baibu, "_lark_cli", lambda: "lark-cli")
monkeypatch.setattr(baibu.subprocess, "run", fake_run)
monkeypatch.setattr(baibu.time, "sleep", sleeps.append)
with pytest.raises(baibu.BaibuApplyError, match="exit=4"):
baibu._export_template(_entry(), tmp_path)
assert len(calls) == 1
assert sleeps == []
def test_safe_cli_detail_redacts_feishu_tokens() -> None:
detail = baibu._safe_cli_detail(
'{"token":"secret-token","message":"?token=query-token"}',
"",
)
assert "secret-token" not in detail
assert "query-token" not in detail
assert detail.count("<redacted>") == 2
def test_latest_batch_import_status_parses_processing_and_complete_summary() -> None:
processing = baibu._latest_batch_import_status(
"您于2026-08-26 09:23:37执行了商品批量导入操作,正在处理中。"
@@ -123,13 +195,13 @@ class _LoginLocator:
class _LoginFrame:
def __init__(self) -> None:
self.username = _LoginLocator()
self.password = _LoginLocator()
self.password_input = _LoginLocator()
self.submit = _LoginLocator()
def locator(self, selector: str):
return {
"input[placeholder='账号名/邮箱/手机号']": self.username,
"input[placeholder='请输入登录密码']": self.password,
"input[placeholder='请输入登录密码']": self.password_input,
"button[type='submit']": _LoginLocator(visible=False),
"input[type='submit']": _LoginLocator(visible=False),
}.get(selector, _LoginLocator(visible=False))
@@ -161,7 +233,7 @@ def test_login_if_needed_fills_the_password_form_inside_an_iframe(monkeypatch) -
assert attempted is True
assert frame.username.filled == ["demo-account"]
assert frame.password.filled == ["demo-password"]
assert frame.password_input.filled == ["demo-password"]
assert frame.submit.click_count == 1
@@ -181,7 +253,7 @@ def test_manual_login_waits_without_filling_the_password_form(monkeypatch) -> No
assert attempted is True
assert frame.username.filled == []
assert frame.password.filled == []
assert frame.password_input.filled == []
assert frame.submit.click_count == 0
@@ -208,7 +280,7 @@ def test_login_if_needed_reports_missing_credentials_without_filling(monkeypatch
raise AssertionError("missing credentials must stop before filling")
assert frame.username.filled == []
assert frame.password.filled == []
assert frame.password_input.filled == []
def test_wait_for_authenticated_stops_on_explicit_login_error(monkeypatch) -> None:
@@ -555,18 +627,18 @@ def test_complete_draft_products_runs_required_sequence_until_count_is_zero(
"draft",
"open:True",
"否",
"下一步",
"提交",
"draft",
"open:False",
"否",
"下一步",
"提交",
"draft",
]
assert clicked == [
("否",),
("下一步",),
("提交", "下一步"),
("否",),
("下一步",),
("提交", "下一步"),
]