test: aggregator coverage for zero_feedback, long_on_market, significant_price_drop
Some checks are pending
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Mirror / mirror (push) Waiting to run

Add 10 new tests covering the three previously untested flag paths:
- zero_feedback: flag fires + composite capped at 35 even with all-20 signals
- long_on_market: fires at >=5 sightings + >=14 days; NOT at <5 sightings or <14 days
- significant_price_drop: fires at >=20% below first-seen; NOT at <20% or no prior price
- established_retailer: duplicate_photo suppressed at feedback>=1000; fires below threshold

Also fix datetime.utcnow() deprecation in aggregator._days_since() and test helper
— replaced with timezone-aware datetime.now(timezone.utc) throughout.
This commit is contained in:
pyr0ball 2026-04-16 13:14:20 -07:00
parent 873b9a1413
commit 7005be02c2
2 changed files with 128 additions and 4 deletions

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
from app.db.models import Seller, TrustScore from app.db.models import Seller, TrustScore
@ -60,9 +60,9 @@ def _days_since(iso: Optional[str]) -> Optional[int]:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
# Normalize to naive UTC so both paths (timezone-aware ISO and SQLite # Normalize to naive UTC so both paths (timezone-aware ISO and SQLite
# CURRENT_TIMESTAMP naive strings) compare correctly. # CURRENT_TIMESTAMP naive strings) compare correctly.
if dt.tzinfo is not None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=None) dt = dt.replace(tzinfo=timezone.utc)
return (datetime.utcnow() - dt).days return (datetime.now(timezone.utc) - dt).days
except ValueError: except ValueError:
return None return None

View file

@ -1,6 +1,14 @@
from datetime import datetime, timedelta, timezone
from app.db.models import Seller from app.db.models import Seller
from app.trust.aggregator import Aggregator from app.trust.aggregator import Aggregator
_ALL_20 = {k: 20 for k in ["account_age", "feedback_count", "feedback_ratio", "price_vs_market", "category_history"]}
def _iso_days_ago(n: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=n)).isoformat()
def test_composite_sum_of_five_signals(): def test_composite_sum_of_five_signals():
agg = Aggregator() agg = Aggregator()
@ -132,3 +140,119 @@ def test_new_account_not_flagged_when_age_absent():
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=scraper_seller) result = agg.aggregate(scores, photo_hash_duplicate=False, seller=scraper_seller)
assert "new_account" not in result.red_flags_json assert "new_account" not in result.red_flags_json
assert "account_under_30_days" not in result.red_flags_json assert "account_under_30_days" not in result.red_flags_json
# ── zero_feedback ─────────────────────────────────────────────────────────────
def test_zero_feedback_adds_flag():
"""seller.feedback_count == 0 must add zero_feedback flag."""
agg = Aggregator()
seller = Seller(
platform="ebay", platform_seller_id="u", username="u",
account_age_days=365, feedback_count=0, feedback_ratio=1.0,
category_history_json="{}",
)
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=False, seller=seller)
assert "zero_feedback" in result.red_flags_json
def test_zero_feedback_caps_composite_at_35():
"""Even with perfect other signals (all 20/20), zero feedback caps composite at 35."""
agg = Aggregator()
seller = Seller(
platform="ebay", platform_seller_id="u", username="u",
account_age_days=365, feedback_count=0, feedback_ratio=1.0,
category_history_json="{}",
)
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=False, seller=seller)
assert result.composite_score <= 35
# ── long_on_market ────────────────────────────────────────────────────────────
def test_long_on_market_flagged_when_thresholds_met():
"""times_seen >= 5 AND listing age >= 14 days → long_on_market fires."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
times_seen=5, first_seen_at=_iso_days_ago(20),
)
assert "long_on_market" in result.red_flags_json
def test_long_on_market_not_flagged_when_too_few_sightings():
"""times_seen < 5 must NOT trigger long_on_market even if listing is old."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
times_seen=4, first_seen_at=_iso_days_ago(30),
)
assert "long_on_market" not in result.red_flags_json
def test_long_on_market_not_flagged_when_too_recent():
"""times_seen >= 5 but only seen for < 14 days → long_on_market must NOT fire."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
times_seen=10, first_seen_at=_iso_days_ago(5),
)
assert "long_on_market" not in result.red_flags_json
# ── significant_price_drop ────────────────────────────────────────────────────
def test_significant_price_drop_flagged():
"""price >= 20% below price_at_first_seen → significant_price_drop fires."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
price=75.00, price_at_first_seen=100.00, # 25% drop
)
assert "significant_price_drop" in result.red_flags_json
def test_significant_price_drop_not_flagged_when_drop_is_small():
"""< 20% drop must NOT trigger significant_price_drop."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
price=95.00, price_at_first_seen=100.00, # 5% drop
)
assert "significant_price_drop" not in result.red_flags_json
def test_significant_price_drop_not_flagged_when_no_prior_price():
"""price_at_first_seen=None (first sighting) must NOT fire significant_price_drop."""
agg = Aggregator()
result = agg.aggregate(
_ALL_20.copy(), photo_hash_duplicate=False, seller=None,
price=50.00, price_at_first_seen=None,
)
assert "significant_price_drop" not in result.red_flags_json
# ── established retailer ──────────────────────────────────────────────────────
def test_established_retailer_suppresses_duplicate_photo():
"""feedback_count >= 1000 (established retailer) must suppress duplicate_photo flag."""
agg = Aggregator()
retailer = Seller(
platform="ebay", platform_seller_id="u", username="u",
account_age_days=1800, feedback_count=5000, feedback_ratio=0.99,
category_history_json="{}",
)
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=retailer)
assert "duplicate_photo" not in result.red_flags_json
def test_non_retailer_does_not_suppress_duplicate_photo():
"""feedback_count < 1000 — duplicate_photo must still fire when hash matches."""
agg = Aggregator()
seller = Seller(
platform="ebay", platform_seller_id="u", username="u",
account_age_days=365, feedback_count=50, feedback_ratio=0.99,
category_history_json="{}",
)
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=seller)
assert "duplicate_photo" in result.red_flags_json