chore: [workflow] 提交工作区代码
This commit is contained in:
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 == [
|
||||
("否",),
|
||||
("下一步",),
|
||||
("提交", "下一步"),
|
||||
("否",),
|
||||
("下一步",),
|
||||
("提交", "下一步"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user