213 lines
7.7 KiB
Python
213 lines
7.7 KiB
Python
from pathlib import Path
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_competitor_feeder_persists_all_match_attempt_outcomes():
|
|
source = (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8")
|
|
migration = (ROOT / "migrations/023_competitor_match_attempts.sql").read_text(encoding="utf-8")
|
|
|
|
assert "attempts_written" in source
|
|
assert "_ensure_competitor_match_attempts_table" in source
|
|
assert "_record_match_attempt" in source
|
|
assert "INSERT INTO competitor_match_attempts" in source
|
|
assert "CAST(:search_terms AS jsonb)" in source
|
|
assert 'attempt_status="matched"' in source
|
|
assert 'attempt_status="low_score"' in source
|
|
assert 'attempt_status="no_result"' in source
|
|
assert 'attempt_status="no_match"' in source
|
|
assert 'attempt_status="error"' in source
|
|
assert "_search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price)" in source
|
|
assert 'attempt_status="needs_review"' in source
|
|
assert "_should_upsert_competitor_price" in source
|
|
assert "replace_legacy_unverified" in source
|
|
assert "identity_v2" in source
|
|
assert "_fetch_expired_identity_skus" in source
|
|
assert "run_expired_identity_refresh" in source
|
|
assert "refresh_known_identity" in source
|
|
assert 'attempt_status="unit_comparable"' in source
|
|
assert 'attempt_status="refresh_unit_comparable"' in source
|
|
assert "mode={getattr(diagnostics, 'comparison_mode'" in source
|
|
assert 'PCHOME_FEEDER_TIMEOUT", "12"' in source
|
|
assert "PChomeCrawler(timeout=REQUEST_TIMEOUT" in source
|
|
|
|
assert "CREATE TABLE IF NOT EXISTS competitor_match_attempts" in migration
|
|
assert "attempt_status" in migration
|
|
assert "search_terms" in migration
|
|
assert "best_match_score" in migration
|
|
assert "error_message" in migration
|
|
assert "idx_comp_match_attempts_sku_source_time" in migration
|
|
|
|
|
|
def test_competitor_match_review_service_closes_human_review_loop():
|
|
service_source = (ROOT / "services/competitor_match_review_service.py").read_text(encoding="utf-8")
|
|
migration = (ROOT / "migrations/039_create_competitor_match_reviews.sql").read_text(encoding="utf-8")
|
|
dashboard_js = (ROOT / "web/static/js/page-dashboard-v2.js").read_text(encoding="utf-8")
|
|
|
|
assert "VALID_REVIEW_ACTIONS" in service_source
|
|
assert "accept_identity" in service_source
|
|
assert "reject_identity" in service_source
|
|
assert "unit_price_required" in service_source
|
|
assert "manual_accepted" in service_source
|
|
assert "manual_rejected" in service_source
|
|
assert "manual_unit_price_required" in service_source
|
|
assert "INSERT INTO competitor_match_reviews" in service_source
|
|
assert "INSERT INTO competitor_prices" in service_source
|
|
assert "INSERT INTO competitor_price_history" in service_source
|
|
assert "manual_review" in service_source
|
|
assert "manual_accept" in service_source
|
|
assert "CREATE TABLE IF NOT EXISTS competitor_match_reviews" in migration
|
|
assert "review_action" in migration
|
|
assert "reviewer_identity" in migration
|
|
assert "candidate_diagnostic" in migration
|
|
assert "idx_comp_match_reviews_sku_source_time" in migration
|
|
assert "runPchomeReviewDecision" in dashboard_js
|
|
assert "/api/pchome-review/" in dashboard_js
|
|
|
|
|
|
def test_competitor_feeder_logs_keyword_parser_fallback(monkeypatch, caplog):
|
|
from services import competitor_price_feeder
|
|
from services import marketplace_product_matcher
|
|
|
|
def broken_build_search_terms(*_args, **_kwargs):
|
|
raise RuntimeError("matcher unavailable")
|
|
|
|
monkeypatch.setattr(marketplace_product_matcher, "build_search_terms", broken_build_search_terms)
|
|
caplog.set_level(logging.DEBUG, logger="services.competitor_price_feeder")
|
|
|
|
terms = competitor_price_feeder._build_search_keywords("理膚寶水 B5 修復霜 40ml")
|
|
|
|
assert terms
|
|
assert "fallback to cleaned product name" in caplog.text
|
|
|
|
|
|
def test_competitor_feeder_refreshes_expired_identity_by_known_product_id(monkeypatch):
|
|
from services.competitor_price_feeder import CompetitorPriceFeeder
|
|
from services.pchome_crawler import PChomeProduct
|
|
|
|
requested = []
|
|
product = PChomeProduct(
|
|
product_id="DDAB01-1900ABCD",
|
|
name="舒特膚 AD 乳液 200ml",
|
|
price=899,
|
|
original_price=999,
|
|
discount=10,
|
|
image_url="",
|
|
product_url="https://24h.pchome.com.tw/prod/DDAB01-1900ABCD",
|
|
stock=50,
|
|
store="24h",
|
|
rating=4.8,
|
|
review_count=12,
|
|
is_on_sale=True,
|
|
crawled_at=datetime.now(),
|
|
)
|
|
|
|
class FakeCrawler:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def fetch_product_details(self, product_ids, batch_size=20):
|
|
requested.extend(product_ids)
|
|
return True, "ok", [product]
|
|
|
|
monkeypatch.setattr("services.pchome_crawler.PChomeCrawler", FakeCrawler)
|
|
feeder = CompetitorPriceFeeder(engine=object())
|
|
writes = []
|
|
attempts = []
|
|
monkeypatch.setattr(
|
|
feeder,
|
|
"_should_upsert_competitor_price",
|
|
lambda *_args, **_kwargs: (True, "same_or_empty_existing"),
|
|
)
|
|
monkeypatch.setattr(
|
|
feeder,
|
|
"_upsert_competitor_price",
|
|
lambda sku, product, score, tags, **kwargs: writes.append({
|
|
"sku": sku,
|
|
"product_id": product.product_id,
|
|
"score": score,
|
|
"tags": tags,
|
|
**kwargs,
|
|
}),
|
|
)
|
|
monkeypatch.setattr(
|
|
feeder,
|
|
"_record_match_attempt",
|
|
lambda *args, **kwargs: attempts.append(kwargs),
|
|
)
|
|
|
|
result = feeder._run_known_identity_refresh_items([{
|
|
"sku": "A001",
|
|
"name": "舒特膚 AD 乳液 200ml",
|
|
"product_id": 1,
|
|
"momo_price": 980,
|
|
"competitor_product_id": "DDAB01-1900ABCD",
|
|
}])
|
|
|
|
assert requested == ["DDAB01-1900ABCD"]
|
|
assert result.matched == 1
|
|
assert writes[0]["product_id"] == "DDAB01-1900ABCD"
|
|
assert "identity_v2" in writes[0]["tags"]
|
|
assert "refresh_known_identity" in writes[0]["tags"]
|
|
assert attempts[0]["attempt_status"] == "matched"
|
|
assert attempts[0]["search_terms"] == ["known_product_id:DDAB01-1900ABCD"]
|
|
|
|
|
|
def test_competitor_feeder_records_unit_comparable_without_price_upsert(monkeypatch):
|
|
from services.competitor_price_feeder import CompetitorPriceFeeder
|
|
from services.pchome_crawler import PChomeProduct
|
|
|
|
product = PChomeProduct(
|
|
product_id="DDAB01-UNIT",
|
|
name="理膚寶水 全面修復霜 B5 40ml",
|
|
price=679,
|
|
original_price=799,
|
|
discount=15,
|
|
image_url="",
|
|
product_url="https://24h.pchome.com.tw/prod/DDAB01-UNIT",
|
|
stock=20,
|
|
store="24h",
|
|
rating=4.7,
|
|
review_count=8,
|
|
is_on_sale=True,
|
|
crawled_at=datetime.now(),
|
|
)
|
|
|
|
class FakeCrawler:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def search_products(self, *_args, **_kwargs):
|
|
return True, "ok", [product]
|
|
|
|
monkeypatch.setattr("services.pchome_crawler.PChomeCrawler", FakeCrawler)
|
|
feeder = CompetitorPriceFeeder(engine=object())
|
|
attempts = []
|
|
writes = []
|
|
monkeypatch.setattr(
|
|
feeder,
|
|
"_record_match_attempt",
|
|
lambda *args, **kwargs: attempts.append(kwargs),
|
|
)
|
|
monkeypatch.setattr(
|
|
feeder,
|
|
"_upsert_competitor_price",
|
|
lambda *args, **kwargs: writes.append((args, kwargs)),
|
|
)
|
|
|
|
result = feeder._run_sku_items([{
|
|
"sku": "A002",
|
|
"name": "理膚寶水 B5 全面修復霜 40ml x2 超值組",
|
|
"product_id": 2,
|
|
"momo_price": 1199,
|
|
}])
|
|
|
|
assert result.matched == 0
|
|
assert result.skipped_low_score == 1
|
|
assert writes == []
|
|
assert attempts[0]["attempt_status"] == "unit_comparable"
|
|
assert "unit_comparable" in attempts[0]["error_message"]
|