Files
ewoooc/tests/test_yahoo_shopping_public_offer_service.py
ogt 4670f80f98
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
feat(growth): automate Yahoo same-item offer canary
2026-07-15 12:43:24 +08:00

512 lines
18 KiB
Python

import json
class _FakeResponse:
def __init__(self, url, body, status_code=200):
self.url = url
self.content = body.encode("utf-8")
self.status_code = status_code
def _search_html(*, product_id="1001", title="LANCOME 蘭蔻 零粉感水凝粉餅 10g #01"):
payload = {
"search": {
"ecsearch": {
"hits": [
{
"ec_productid": product_id,
"ec_title": title,
"ec_item_url": (
f"https://tw.buy.yahoo.com/gdsale/product-{product_id}.html"
),
"ec_price": "1890",
"ec_listprice": "2100",
"ec_image": "https://img.example.test/1001.jpg",
"ec_brand": "LANCOME",
"ec_storename": "Yahoo購物中心",
"ec_has_stock": "1",
"ec_quality_rating": "4.9",
"ec_rating_count": "18",
}
]
}
}
}
return (
"<html><head><title>Yahoo 搜尋</title></head><body>"
f"<script id='isoredux-data' type='application/json'>{json.dumps(payload)}</script>"
"</body></html>"
)
def _product_html(*, product_id="1001", title="LANCOME 蘭蔻 零粉感水凝粉餅 10g #01"):
payload = {
"@context": "https://schema.org",
"@type": "Product",
"sku": product_id,
"name": f"{title} | Yahoo購物中心",
"brand": {"@type": "Brand", "name": "LANCOME"},
"gtin13": "3614271234567",
"image": ["https://img.example.test/1001.jpg"],
"offers": {
"@type": "Offer",
"price": "1890",
"priceCurrency": "TWD",
"url": f"https://tw.buy.yahoo.com/gdsale/gdsale.asp?gdid={product_id}",
"seller": {"@type": "Organization", "name": "Yahoo購物中心"},
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"ratingCount": "18",
},
}
return (
"<html><head><title>商品頁</title></head><body>"
f"<script type='application/ld+json'>{json.dumps(payload)}</script>"
"</body></html>"
)
def test_yahoo_public_parsers_extract_search_and_product_jsonld():
from services.yahoo_shopping_public_offer_service import (
parse_yahoo_product_jsonld,
parse_yahoo_search_html,
)
search_url = "https://tw.buy.yahoo.com/search/product?p=lancome"
product_url = "https://tw.buy.yahoo.com/gdsale/product-1001.html"
search = parse_yahoo_search_html(_search_html(), search_url=search_url)
product = parse_yahoo_product_jsonld(
_product_html(),
product_url=product_url,
)
assert search["success"] is True
assert search["candidate_count"] == 1
assert search["candidates"][0]["product_id"] == "1001"
assert search["candidates"][0]["search_price"] == 1890
assert search["content_sha256"]
assert product["success"] is True
assert product["product"]["product_id"] == "1001"
assert product["product"]["price"] == 1890
assert product["product"]["currency"] == "TWD"
assert product["product"]["gtin13"] == "3614271234567"
assert product["product_fingerprint_sha256"]
def test_yahoo_source_contract_rejects_missing_pchome_spec():
from services.yahoo_shopping_public_offer_service import _source_contract_guard
product_url = "https://tw.buy.yahoo.com/gdsale/product-1001.html"
guard = _source_contract_guard(
target={
"target_public_evidence": {
"spec": "1W2",
"current_price": 1690,
}
},
search_candidate={"product_id": "1001"},
product_readback={
"success": True,
"product": {
"product_id": "1001",
"name": "Estee Lauder 粉持久完美持妝粉底",
"price": 1590,
"currency": "TWD",
"product_url": product_url,
},
},
)
assert guard["passed"] is False
assert "target_spec_missing_from_source_title" in guard["reasons"]
assert guard["direct_price_write_allowed"] is False
def test_yahoo_source_contract_rejects_multi_variant_group_even_when_spec_appears():
from services.yahoo_shopping_public_offer_service import _source_contract_guard
product_url = "https://tw.buy.yahoo.com/gdsale/product-2002.html"
guard = _source_contract_guard(
target={
"target_public_evidence": {
"spec": "無香",
"current_price": 179,
}
},
search_candidate={"product_id": "2002"},
product_readback={
"success": True,
"product": {
"product_id": "2002",
"name": "GATSBY 魔法激凍體用噴霧170ml(海洋冰封/無香薄荷)",
"price": 169,
"currency": "TWD",
"product_url": product_url,
},
},
)
assert guard["passed"] is False
assert "source_multi_variant_listing" in guard["reasons"]
def test_yahoo_source_contract_rejects_unavailable_target_or_source_stock():
from services.yahoo_shopping_public_offer_service import _source_contract_guard
product_url = "https://tw.buy.yahoo.com/gdsale/product-3003.html"
guard = _source_contract_guard(
target={
"target_public_evidence": {
"spec": "100ml",
"current_price": 975,
"quantity": 0,
}
},
search_candidate={"product_id": "3003", "stock_status": "out_of_stock"},
product_readback={
"success": True,
"product": {
"product_id": "3003",
"name": "施巴 抗乾敏滋潤浴露100ml",
"price": 299,
"currency": "TWD",
"product_url": product_url,
},
},
)
assert guard["passed"] is False
assert "pchome_stock_not_available" in guard["reasons"]
assert "source_stock_not_confirmed" in guard["reasons"]
def test_yahoo_source_contract_rejects_sales_report_price_fallback():
from services.yahoo_shopping_public_offer_service import _source_contract_guard
product_url = "https://tw.buy.yahoo.com/gdsale/product-4004.html"
guard = _source_contract_guard(
target={
"target_public_evidence": {
"spec": "100ml",
"current_price": 975,
"current_price_field": "sales_report_fallback",
}
},
search_candidate={"product_id": "4004", "stock_status": "in_stock"},
product_readback={
"success": True,
"product": {
"product_id": "4004",
"name": "施巴 抗乾敏滋潤浴露100ml",
"price": 999,
"currency": "TWD",
"product_url": product_url,
},
},
)
assert guard["passed"] is False
assert "pchome_current_price_missing" in guard["reasons"]
def test_yahoo_source_contract_accepts_product_detail_instock_evidence():
from services.yahoo_shopping_public_offer_service import _source_contract_guard
product_url = "https://tw.buy.yahoo.com/gdsale/product-5005.html"
guard = _source_contract_guard(
target={
"target_public_evidence": {
"spec": "100ml",
"current_price": 975,
"current_price_field": "P",
}
},
search_candidate={"product_id": "5005", "stock_status": "unknown"},
product_readback={
"success": True,
"product": {
"product_id": "5005",
"name": "施巴 抗乾敏滋潤浴露100ml",
"price": 999,
"currency": "TWD",
"availability": "https://schema.org/InStock",
"product_url": product_url,
},
},
)
assert guard["passed"] is True
assert "source_stock_not_confirmed" not in guard["reasons"]
def test_public_source_stream_reader_stops_at_byte_limit():
from services.public_source_http_service import read_bounded_response_text
class StreamingResponse:
status_code = 200
headers = {}
closed = False
def iter_content(self, *, chunk_size):
assert chunk_size > 0
yield b"1234"
yield b"5678"
def close(self):
self.closed = True
response = StreamingResponse()
try:
read_bounded_response_text(response, max_bytes=6)
except ValueError as exc:
assert str(exc) == "public_source_response_too_large"
else:
raise AssertionError("oversized public response was accepted")
assert response.closed is True
def test_yahoo_search_builds_guarded_candidate_from_two_public_sources(monkeypatch):
from services.yahoo_shopping_public_offer_service import (
search_yahoo_candidates_for_pchome_products,
)
monkeypatch.setenv("PCHOME_GROWTH_YAHOO_MAX_TERMS_PER_TARGET", "1")
monkeypatch.setenv("PCHOME_GROWTH_YAHOO_DETAIL_CANDIDATES_PER_TARGET", "1")
pchome_payload = {
"PCH-1": {
"Id": "PCH-1",
"Name": "LANCOME 蘭蔻 零粉感水凝粉餅 10g",
"Spec": "#01",
"Price": {"P": 1690, "M": 1890},
"Qty": 3,
}
}
def fake_get(url, *, timeout):
assert timeout > 0
if "ecapi.pchome.com.tw" in url:
body = f"try{{jsonp({json.dumps(pchome_payload)});}}catch(e){{}}"
return _FakeResponse(url, body)
if "/search/product" in url:
return _FakeResponse(url, _search_html())
if "/gdsale/" in url:
return _FakeResponse(url, _product_html())
raise AssertionError(url)
success, message, candidates = search_yahoo_candidates_for_pchome_products(
[
{
"product_id": "PCH-1",
"name": "LANCOME 蘭蔻 零粉感水凝粉餅 10g",
"price": 1650,
}
],
1,
request_get=fake_get,
)
assert success is True
assert "1 筆商品頁候選" in message
assert len(candidates) == 1
candidate = candidates[0]
assert candidate["target_pchome_price"] == 1690
assert candidate["target_public_evidence"]["spec"] == "#01"
assert candidate["target_public_evidence"]["current_price_field"] == "P"
assert candidate["target_public_evidence"]["price_fields"] == {
"P": 1690.0,
"M": 1890.0,
}
assert candidate["source_contract_guard"]["passed"] is True
assert candidate["source_provenance"]["content_sha256"]
assert candidate["source_provenance"]["product_content_sha256"]
assert candidate["auto_compare_type"] == "total_price"
def test_yahoo_runtime_readback_uses_fresh_compact_receipt(tmp_path):
from services.yahoo_shopping_public_offer_service import (
_write_compact_receipt,
build_yahoo_shopping_runtime_readback,
)
identity = {
"trace_id": "trace-yahoo",
"run_id": "run-yahoo",
"work_item_id": "GROWTH-P0-001-C",
}
result = {
"success": True,
"data": {
"identity": identity,
"terminal_status": "verified_write_readback",
"next_machine_action": "continue_next_revenue_weighted_batch",
"target_count": 2,
"candidate_count": 2,
"auto_compare_count": 2,
"review_count": 0,
"external_offer_sync": {
"written_count": 2,
"writes_database_count": 2,
},
"source_activation": {"applied": True, "writes_database_count": 1},
"source_rollback": {"writes_database_count": 0},
"post_verifier": {"readback_pass_count": 2},
"receipt_persistence": {"success": True},
"reconciliation_receipt": {
"source_receipt": {"source": "public_search"},
"candidate_verification": {
"verified_candidates": [
{
"target_pchome_product_id": "PCH-1",
"product_id": "1001",
"product_url": "https://tw.buy.yahoo.com/gdsale/product-1001.html",
"price": 1890,
"currency": "TWD",
"target_match_score": 1.0,
"same_item_candidate_fingerprint": "candidate-fingerprint",
"source_provenance": {
"product_fingerprint_sha256": "product-fingerprint"
},
}
]
},
},
},
}
persisted = _write_compact_receipt(result, receipt_root=tmp_path)
readback = build_yahoo_shopping_runtime_readback(receipt_root=tmp_path)
assert persisted["success"] is True
assert readback["status"] == "ready"
assert readback["has_candidate_runtime_data"] is True
assert readback["candidate_offer_count"] == 2
assert readback["verified_candidate_count"] == 2
assert readback["formal_source_activated"] is True
assert readback["writes_database_count"] == 0
def test_yahoo_runtime_forces_check_mode_before_controlled_apply(monkeypatch):
from services import external_marketplace_canary_service as canary_service
from services import pchome_growth_momo_backfill_service as backfill_service
from services import yahoo_shopping_public_offer_service as runtime
calls = []
def fake_sync(_engine, candidates, *, dry_run, **_kwargs):
calls.append(dry_run)
return {
"success": True,
"status": "dry_run" if dry_run else "synced",
"candidate_count": len(candidates),
"selected_count": len(candidates),
"written_count": 0 if dry_run else len(candidates),
"writes_database_count": 0 if dry_run else len(candidates),
}
def fake_backfill(_engine, **kwargs):
sync = kwargs["sync_func"](_engine, [{"product_id": "Y-1"}])
return {
"success": True,
"data": {
"external_offer_sync": sync,
"identity": {"run_id": "run-yahoo-check-mode"},
"reconciliation_receipt": {},
},
}
monkeypatch.setattr(
canary_service,
"sync_targeted_marketplace_candidates_to_external_offers",
fake_sync,
)
monkeypatch.setattr(
backfill_service,
"run_pchome_growth_momo_backfill",
fake_backfill,
)
monkeypatch.setattr(
runtime,
"_write_compact_receipt",
lambda _result, receipt_root: {
"success": True,
"receipt_path": str(receipt_root),
},
)
result = runtime.run_pchome_growth_yahoo_backfill(
object(),
receipt_root="/tmp/yahoo-check-mode-test",
)
assert calls == [True, False]
sync = result["data"]["external_offer_sync"]
assert sync["written_count"] == 1
assert sync["check_mode_receipt"]["status"] == "dry_run"
assert sync["check_mode_receipt"]["writes_database_count"] == 0
def test_yahoo_scheduler_adapter_records_terminal_receipt(monkeypatch):
import sqlalchemy
from services import pchome_growth_yahoo_scheduler_task as scheduler_task
from services import yahoo_shopping_public_offer_service as runtime
captured = {"stats": []}
class FakeEngine:
def dispose(self):
captured["disposed"] = True
monkeypatch.setenv("PCHOME_GROWTH_YAHOO_BACKFILL_LIMIT", "5")
monkeypatch.setattr(sqlalchemy, "create_engine", lambda _path: FakeEngine())
monkeypatch.setattr(
runtime,
"run_pchome_growth_yahoo_backfill",
lambda engine, limit: {
"success": True,
"message": "verified",
"data": {
"identity": {
"trace_id": "trace-scheduler",
"run_id": "run-scheduler",
"work_item_id": "GROWTH-P0-001-C",
},
"terminal_status": "verified_with_coverage_gain",
"next_machine_action": "continue_next_revenue_weighted_batch",
"scanned_products": limit,
"candidate_count": 4,
"auto_compare_count": 3,
"review_count": 1,
"external_offer_sync": {"written_count": 3},
"post_verifier": {
"readback_pass_count": 3,
"status": "verified",
},
"source_activation": {
"status": "activated",
"applied": True,
},
"artifact_receipt": {
"status": "persisted_and_verified",
"receipt_sha256": "receipt-hash",
},
},
},
)
result = scheduler_task.run_scheduled_pchome_growth_yahoo_backfill(
save_stats=lambda key, value: captured["stats"].append((key, value))
)
assert result["success"] is True
assert captured["disposed"] is True
key, stats = captured["stats"][0]
assert key == "pchome_growth_yahoo_backfill"
assert stats["status"] == "Success"
assert stats["work_item_id"] == "GROWTH-P0-001-C"
assert stats["readback_pass_count"] == 3
assert stats["source_activated"] is True
assert stats["artifact_sha256"] == "receipt-hash"