From e16b7c65f600a5afaeedf14a2b9234c39c361159 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 5 May 2026 22:13:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(steam-sniper):=20URL=20slug=20as=20authorit?= =?UTF-8?q?ative=20resolver=20=E2=80=94=20kills=20'Tiger=20Tooth=20?= =?UTF-8?q?=E2=86=92=20Scorched'=20skin=20mix-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лёхин feedback по extension v1.4: при добавлении ножей через расширение вместо Tiger Tooth попадал Scorched (та же модель, другой скин). Stволы аналогично. Из всех ножей только 1-2 добавились правильно. Корень: extension шлёт item_name парсенный из DOM h1, но lis-skins-страницы содержат карусели похожих скинов — первый h1 с wear-маркером может быть related-item, а не главный. Сервер доверял DOM-имени, exact-match'ил его в каталоге и писал левый скин. URL же всегда правильный. Фикс — приоритезировать URL slug над DOM-именем в /api/lists: - _slugify_name(): генерирует канонический slug из имени каталога (drops ★/™, lowercase, non-alphanumeric → dash) - _slug_from_source_url(): извлекает последний path-сегмент URL - _rebuild_slug_index(): индексирует 3 формы slug на каждый item: 1. generated (ASCII-only fallback) 2. lis-skins-provided URL slug (percent-encoded — authoritative) 3. URL-decoded variant (если location.href декодирован браузером) - _resolve_from_source_url(): O(1) lookup, возвращает canonical name - /api/lists добавлен Шаг 0 — URL-резолвер выше DOM-резолва - WARNING лог при DOM/URL mismatch — диагностика когда DOM врёт Дополнительно: - extension/background.js: 8s timeout через AbortController + явное сообщение "сервер не ответил, проверь дашборд" вместо silent void - extension/content.js: toast показывает canonical name из ответа сервера ("Добавлено → ★ Karambit | Tiger Tooth (Field-Tested)") — юзер видит что улетело до закрытия страницы; PATCH таргета идёт по canonical name - extension manifest: bump 1.4 → 1.5 - static/js/lists.js: in-flight lock в toggleListItem — палиатив против дубль-кликов на cases-карточках с двумя toggle-кнопками - _resolve_item_name: WARNING лог на каждый Steam-API fallback (по фидбэку Codex) — observability для решения нужно ли refuse'ать вместо silent first-match. Время + chosen hash_name + resolved name. - /api/lists endpoint: structured лог received → resolve path → resolved Тесты: tests/test_source_url_resolution.py — 17 кейсов, главный регресс test_tiger_tooth_url_resolves_to_tiger_tooth (URL Tiger Tooth + DOM Scorched → Tiger Tooth). Encoded/decoded slug variants. StatTrak namespace isolation. 133/133 passed. Smoke на проде: POST /api/lists item_name="★ Karambit | Scorched" + URL=tiger-tooth/ → "★ Karambit | Tiger Tooth (Factory New)" via source_url ✓ Не сделано (осознанно): - Steam-API fallback оставлен по поведению (только лог) — после URL-резолвера он хитится только для items не в каталоге; решение по refuse — после недели наблюдения логов - Cases policy "в один список" — ждём serverных логов на повтор жалобы чтобы понять кто шлёт левый list_type (UI или event-double-fire) - Security findings (CORS, /api без auth, HTTP) остаются deferred — юзер использует только с Лёхой Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/steam-sniper/extension/background.js | 29 ++- tools/steam-sniper/extension/content.js | 12 +- tools/steam-sniper/extension/manifest.json | 4 +- tools/steam-sniper/server.py | 174 ++++++++++++++- tools/steam-sniper/static/js/lists.js | 13 ++ .../tests/test_source_url_resolution.py | 199 ++++++++++++++++++ 6 files changed, 415 insertions(+), 16 deletions(-) create mode 100644 tools/steam-sniper/tests/test_source_url_resolution.py diff --git a/tools/steam-sniper/extension/background.js b/tools/steam-sniper/extension/background.js index e87a51c..f2437d0 100644 --- a/tools/steam-sniper/extension/background.js +++ b/tools/steam-sniper/extension/background.js @@ -3,10 +3,23 @@ const API_BASE = "http://72.56.37.150"; const DEFAULT_USER = "lesha"; +const REQUEST_TIMEOUT_MS = 8000; + +// fetch wrapper with hard timeout — raw fetch never aborts, leading to silent +// drops (item appears not added, but actual server state unknown). +async function fetchWithTimeout(url, opts, timeoutMs) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + return await fetch(url, { ...opts, signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } +} async function addToList(itemName, listType, sourceUrl) { try { - const res = await fetch(`${API_BASE}/api/lists`, { + const res = await fetchWithTimeout(`${API_BASE}/api/lists`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -15,21 +28,24 @@ async function addToList(itemName, listType, sourceUrl) { list_type: listType, source_url: sourceUrl || null, }), - }); + }, REQUEST_TIMEOUT_MS); const data = await res.json().catch(() => ({})); // /api/lists returns 201 on new, 200 if already exists — both are fine if ((res.status === 200 || res.status === 201) && data.ok !== false) { - return { ok: true }; + return { ok: true, item_name: data.item_name, resolved_via: data.resolved_via }; } return { ok: false, error: data.error || `HTTP ${res.status}` }; } catch (err) { + if (err.name === "AbortError") { + return { ok: false, error: `Сервер не ответил за ${REQUEST_TIMEOUT_MS / 1000}с — проверь дашборд, мог уйти в БД на левый скин` }; + } return { ok: false, error: err.message || "Network error" }; } } async function setTargets(itemName, listType, targetBelow, targetAbove) { try { - const res = await fetch(`${API_BASE}/api/lists/target`, { + const res = await fetchWithTimeout(`${API_BASE}/api/lists/target`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -39,13 +55,16 @@ async function setTargets(itemName, listType, targetBelow, targetAbove) { target_below_rub: targetBelow, target_above_rub: targetAbove, }), - }); + }, REQUEST_TIMEOUT_MS); const data = await res.json().catch(() => ({})); if (res.ok && data.ok !== false) { return { ok: true }; } return { ok: false, error: data.error || `HTTP ${res.status}` }; } catch (err) { + if (err.name === "AbortError") { + return { ok: false, error: `Таргет не сохранён за ${REQUEST_TIMEOUT_MS / 1000}с — проверь дашборд` }; + } return { ok: false, error: err.message || "Network error" }; } } diff --git a/tools/steam-sniper/extension/content.js b/tools/steam-sniper/extension/content.js index af97dbb..a8c288a 100644 --- a/tools/steam-sniper/extension/content.js +++ b/tools/steam-sniper/extension/content.js @@ -250,9 +250,13 @@ return; } + // Server returns canonical name — use it for targets so we PATCH the same row + const canonicalName = addResp.item_name || itemName; + const nameMismatch = canonicalName !== itemName; + if (withTargets && (below !== null || above !== null)) { const targetResp = await setTargetsRequest( - itemName, + canonicalName, listType, below, above @@ -270,12 +274,14 @@ const parts = []; if (below !== null) parts.push(`🔴 ${below} ₽`); if (above !== null) parts.push(`🟢 ${above} ₽`); + const suffix = nameMismatch ? ` → ${canonicalName}` : ""; showToast( - `Добавлено в ${listLabel.toLowerCase()} (${parts.join(", ")})`, + `Добавлено в ${listLabel.toLowerCase()} (${parts.join(", ")})${suffix}`, false ); } else { - showToast(`Добавлено в ${listLabel.toLowerCase()}`, false); + const suffix = nameMismatch ? ` → ${canonicalName}` : ""; + showToast(`Добавлено в ${listLabel.toLowerCase()}${suffix}`, false); } closeTargetForm(); } diff --git a/tools/steam-sniper/extension/manifest.json b/tools/steam-sniper/extension/manifest.json index cee01bf..f307b8e 100644 --- a/tools/steam-sniper/extension/manifest.json +++ b/tools/steam-sniper/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Steam Sniper", - "version": "1.4", - "description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.4: robust URL wear parser + source_url to backend for fallback.", + "version": "1.5", + "description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.5: 8s timeout + URL slug as authoritative resolver (fixes Tiger Tooth → Scorched skin mix-up).", "permissions": [], "host_permissions": [ "http://72.56.37.150/*" diff --git a/tools/steam-sniper/server.py b/tools/steam-sniper/server.py index 44870a2..42ca7c5 100644 --- a/tools/steam-sniper/server.py +++ b/tools/steam-sniper/server.py @@ -46,6 +46,7 @@ # --- Module-level state --- _prices: dict[str, dict] = {} # {name_lower: {name, price, url, count}} +_slug_to_name: dict[str, str] = {} # {url_slug: canonical_name} for source_url-based resolution _category_counts: dict[str, int] = {} # {category: count} for sidebar _image_cache: dict[str, str] = {} # {name_lower: image_url} from ByMykel API _item_meta: dict[str, dict[str, str]] = {} # {name_lower: {rarity_name, rarity_label, rarity_color}} @@ -637,6 +638,9 @@ async def _collect_once(send_list_alerts: bool = True) -> None: logger.error("Failed to fetch lis-skins: %s", e) return + # Rebuild slug→name index used by /api/lists for source_url-based resolution + _rebuild_slug_index() + # Rebuild category counts for catalog sidebar counts: dict[str, int] = {} for item in _prices.values(): @@ -1083,6 +1087,102 @@ def _wear_from_source_url(source_url: str | None) -> str | None: return None +def _slugify_name(name: str) -> str: + """Generate lis-skins-style URL slug from canonical item name. + + Drops decorative chars (★, ™), lowercases, and replaces all non-alphanumeric + runs with single dashes. Mirrors lis-skins' URL convention so we can match + a source_url back to the exact catalog name (skin + wear). + + Examples: + '★ Karambit | Tiger Tooth (Field-Tested)' → 'karambit-tiger-tooth-field-tested' + 'StatTrak™ AK-47 | Redline (Field-Tested)' → 'stattrak-ak-47-redline-field-tested' + 'Glock-18 | Water Elemental (Minimal Wear)' → 'glock-18-water-elemental-minimal-wear' + """ + s = name.replace("★", "").replace("™", "") + s = s.lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def _slug_from_source_url(source_url: str | None) -> str | None: + """Extract last path segment (item slug) from a lis-skins source URL. + + Returns the slug in its on-the-wire (percent-encoded) lowercase form — + `_rebuild_slug_index` registers BOTH encoded and decoded variants so the + lookup matches whether the caller sent `%E2%98%85-karambit-...` or the + decoded `★-karambit-...`. + """ + if not source_url: + return None + try: + from urllib.parse import urlparse + + path = urlparse(source_url).path.rstrip("/").lower() + except Exception: + return None + if not path: + return None + return path.rsplit("/", 1)[-1] or None + + +def _rebuild_slug_index() -> None: + """Rebuild {url_slug: canonical_name} index from current _prices. + + Indexes three slug forms per item to bullet-proof against URL-encoding + variants the caller might send: + 1. Generated slug from canonical name (★/™ stripped, ASCII). + 2. Lis-skins-provided URL slug (percent-encoded form, e.g. %e2%98%85-...). + 3. Decoded form of the URL slug (with literal ★, etc.) — for callers + whose `location.href` was already URL-decoded by the browser. + Lis-skins-provided forms (2, 3) override generated form (1) on collision — + they're authoritative. + """ + global _slug_to_name + from urllib.parse import unquote + + new_index: dict[str, str] = {} + for item in _prices.values(): + name = item.get("name", "") + if not name: + continue + # Form 1: generated slug — always available, ASCII-only + gen_slug = _slugify_name(name) + if gen_slug: + new_index.setdefault(gen_slug, name) + # Forms 2, 3: lis-skins-provided URL slug (encoded + decoded variants) + url = item.get("url", "") + if url: + url_slug = _slug_from_source_url(url) + if url_slug: + new_index[url_slug] = name + decoded = unquote(url_slug) + if decoded and decoded != url_slug: + new_index[decoded] = name + _slug_to_name = new_index + + +def _resolve_from_source_url(source_url: str | None) -> str | None: + """Resolve a lis-skins URL to canonical catalog name via slug match. + + Tries both the on-the-wire (percent-encoded) and URL-decoded slug forms + so we match regardless of whether `location.href` was decoded by the + browser before being sent. + """ + slug = _slug_from_source_url(source_url) + if not slug: + return None + hit = _slug_to_name.get(slug) + if hit: + return hit + from urllib.parse import unquote + + decoded = unquote(slug) + if decoded and decoded != slug: + return _slug_to_name.get(decoded) + return None + + def _resolve_item_name(name: str) -> str: """Normalize list item names to canonical lis-skins English names. @@ -1120,6 +1220,16 @@ def _resolve_item_name(name: str) -> str: continue if any("\u0400" <= c <= "\u04ff" for c in candidate): + # Log every Steam-API fallback so we can monitor whether this code + # path is still hit after the URL slug resolver took over for /api/lists. + # Per Codex feedback: keep behavior, but make it observable so we can + # later decide whether to refuse instead of silent first-match. + import time as _time + _t0 = _time.perf_counter() + logger.warning( + "[resolve] Steam-API fallback START: candidate=%r requested_wear=%r", + candidate, requested_wear, + ) steam_results: list[dict] = [] for query in _steam_search_queries_for_ru_name(candidate): steam_results = _steam_search(query) @@ -1135,20 +1245,38 @@ def _resolve_item_name(name: str) -> str: and _localized_name_matches(candidate, steam_item.get("name_ru", "")) and _wear_matches_requested(lis_item["name"], requested_wear) ): + logger.warning( + "[resolve] Steam-API HIT (localized): candidate=%r -> hash=%r -> %r (%.2fs)", + candidate, steam_item["hash_name"], lis_item["name"], _time.perf_counter() - _t0, + ) return lis_item["name"] break for steam_item in steam_results: lis_item = _prices.get(steam_item["hash_name"].lower()) if lis_item and _wear_matches_requested(lis_item["name"], requested_wear): + logger.warning( + "[resolve] Steam-API HIT (first-match, NO localized validation): " + "candidate=%r -> hash=%r -> %r (%.2fs) -- POSSIBLE WRONG SKIN", + candidate, steam_item["hash_name"], lis_item["name"], _time.perf_counter() - _t0, + ) return lis_item["name"] en_query = _translate_ru_to_en(candidate) if en_query: matched = _match_catalog_name(en_query, requested_wear=requested_wear) if matched: + logger.warning( + "[resolve] Steam-API HIT (RU->EN translate): candidate=%r -> en=%r -> %r (%.2fs)", + candidate, en_query, matched, _time.perf_counter() - _t0, + ) return matched + logger.warning( + "[resolve] Steam-API MISS: candidate=%r results=%d (%.2fs) -- returning raw", + candidate, len(steam_results), _time.perf_counter() - _t0, + ) + return raw @@ -1637,23 +1765,48 @@ def get_alerts(limit: int = Query(default=20, le=100)) -> dict: def add_list_item_endpoint(body: ListItemRequest) -> JSONResponse | dict: """Add item to user's personal list (LIST-02). - Wear resolution priority (early-fallback): - 1. If body.item_name has no wear AND source_url provides one → augment raw name first. - This prevents _resolve_item_name() (Steam-search / RU-translate path) from picking - a random wear before source_url could help. + Resolution priority: + 0. source_url slug match against catalog (authoritative — URL is single source + of truth on lis-skins; DOM title can mislead when page has multiple skins). + 1. If item_name has no wear AND source_url provides one → augment raw name. 2. Resolve via catalog match (with wear-aware logic, ambiguity-safe). 3. If still ambiguous → 400 wear_required. """ if body.list_type not in ("favorite", "wishlist"): return JSONResponse({"error": "list_type must be 'favorite' or 'wishlist'"}, status_code=400) + logger.info( + "[/api/lists] received: user=%r list_type=%r item_name=%r source_url=%r", + body.user, body.list_type, body.item_name, body.source_url, + ) + + # Step 0: source_url slug match — the most reliable resolver. + # Lis-skins URL slug uniquely identifies skin + wear; DOM h1 may be wrong + # on pages with related-item carousels. Use this first when available. + url_resolved = _resolve_from_source_url(body.source_url) + if url_resolved: + # Surface DOM-vs-URL mismatch so we can spot extension's getItemName() + # picking carousel/related-item h1 instead of the page's main item. + if body.item_name and url_resolved.lower() != body.item_name.strip().lower(): + logger.warning( + "[/api/lists] DOM/URL mismatch: dom=%r url=%r resolved=%r", + body.item_name, body.source_url, url_resolved, + ) + else: + logger.info( + "[/api/lists] resolved via source_url slug: %r (item_name=%r)", + url_resolved, body.item_name, + ) + db.add_list_item(user_id=body.user, item_name=url_resolved, list_type=body.list_type) + return {"ok": True, "item_name": url_resolved, "resolved_via": "source_url"} + # Step 1: early augment from source_url if name lacks wear raw_name = body.item_name if _wear_code_from_query(raw_name) is None: url_wear = _wear_from_source_url(body.source_url) if url_wear: raw_name = f"{raw_name.strip()} ({url_wear})" - logger.info("Augmented item_name from source_url: %r", raw_name) + logger.info("[/api/lists] augmented item_name from source_url wear: %r", raw_name) # Step 2: catalog resolve (ambiguity-safe — refuses to silently pick wear) resolved_name = _resolve_item_name(raw_name) @@ -1661,6 +1814,10 @@ def add_list_item_endpoint(body: ListItemRequest) -> JSONResponse | dict: # Step 3: still ambiguous → reject if requested_wear is None and _has_multiple_wear_variants(resolved_name): + logger.warning( + "[/api/lists] rejecting ambiguous wear: item_name=%r resolved=%r source_url=%r", + body.item_name, resolved_name, body.source_url, + ) return JSONResponse( { "error": "wear_required", @@ -1673,8 +1830,13 @@ def add_list_item_endpoint(body: ListItemRequest) -> JSONResponse | dict: }, status_code=400, ) + via = "catalog" if _prices.get(resolved_name.lower()) else "fallback_raw" + logger.info( + "[/api/lists] resolved via %s: item_name=%r → %r", + via, body.item_name, resolved_name, + ) db.add_list_item(user_id=body.user, item_name=resolved_name, list_type=body.list_type) - return {"ok": True, "item_name": resolved_name} + return {"ok": True, "item_name": resolved_name, "resolved_via": via} @app.delete("/api/lists") diff --git a/tools/steam-sniper/static/js/lists.js b/tools/steam-sniper/static/js/lists.js index 669e9de..2441a83 100644 --- a/tools/steam-sniper/static/js/lists.js +++ b/tools/steam-sniper/static/js/lists.js @@ -14,6 +14,11 @@ let _wishlistItems = []; // Price cache: populated by catalog.js after each loadCatalog let _priceCache = new Map(); // name -> { price_rub, url, category, count, name } +// In-flight toggle locks to prevent double-clicks racing into duplicate +// POST/DELETE pairs (cases tab has both fav+wish buttons on one card — +// rapid clicks were landing in both lists). +const _inFlight = new Set(); // keys: `${listType}:${itemName}` + // --- Exports --- /** @@ -119,6 +124,12 @@ export function updateCardIndicators() { // --- Internal --- async function toggleListItem(itemName, listType) { + // Drop duplicate clicks while a toggle is in flight (prevents rapid clicks + // on cases-tab dual buttons from racing into duplicate POSTs / both lists). + const lockKey = `${listType}:${itemName}`; + if (_inFlight.has(lockKey)) return; + _inFlight.add(lockKey); + const inList = isInList(itemName, listType); // Optimistic UI: update set + DOM immediately @@ -167,9 +178,11 @@ async function toggleListItem(itemName, listType) { set.delete(itemName); } updateCardIndicators(); + _inFlight.delete(lockKey); return; } + _inFlight.delete(lockKey); events.emit('lists:changed', { itemName, listType, action: inList ? 'removed' : 'added' }); } diff --git a/tools/steam-sniper/tests/test_source_url_resolution.py b/tools/steam-sniper/tests/test_source_url_resolution.py new file mode 100644 index 0000000..814551f --- /dev/null +++ b/tools/steam-sniper/tests/test_source_url_resolution.py @@ -0,0 +1,199 @@ +"""Source URL → canonical name resolution (the Tiger Tooth → Scorched fix). + +When user adds an item via extension, the source_url's slug is the most reliable +identifier — DOM h1 can return a related-skin's name on pages with carousels. + +Run: cd tools/steam-sniper && uv run pytest tests/test_source_url_resolution.py -v +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest + +import server + + +@pytest.fixture(autouse=True) +def fake_catalog(monkeypatch: pytest.MonkeyPatch) -> None: + """Two knife skins (Tiger Tooth + Scorched) sharing same model — exact case from user feedback.""" + items = [ + # ★ Karambit knives — model Karambit, two different skins + { + "name": "★ Karambit | Tiger Tooth (Field-Tested)", + "price": 1500.0, + "url": "https://lis-skins.com/market/csgo/karambit-tiger-tooth-field-tested/", + "count": 5, + }, + { + "name": "★ Karambit | Tiger Tooth (Minimal Wear)", + "price": 1700.0, + "url": "https://lis-skins.com/market/csgo/karambit-tiger-tooth-minimal-wear/", + "count": 3, + }, + { + "name": "★ Karambit | Scorched (Field-Tested)", + "price": 800.0, + "url": "https://lis-skins.com/market/csgo/karambit-scorched-field-tested/", + "count": 8, + }, + # AK-47 with hyphen in name — slug should preserve "ak-47" + { + "name": "AK-47 | Redline (Field-Tested)", + "price": 50.0, + "url": "https://lis-skins.com/market/csgo/ak-47-redline-field-tested/", + "count": 100, + }, + # StatTrak™ variant + { + "name": "StatTrak™ AK-47 | Redline (Field-Tested)", + "price": 120.0, + "url": "https://lis-skins.com/market/csgo/stattrak-ak-47-redline-field-tested/", + "count": 30, + }, + ] + fake = {it["name"].lower(): it for it in items} + monkeypatch.setattr(server, "_prices", fake) + server._rebuild_slug_index() + + +# --- _slugify_name --- + + +def test_slugify_drops_decorations(): + assert server._slugify_name("★ Karambit | Tiger Tooth (Field-Tested)") == "karambit-tiger-tooth-field-tested" + + +def test_slugify_handles_stattrak(): + assert server._slugify_name("StatTrak™ AK-47 | Redline (Field-Tested)") == "stattrak-ak-47-redline-field-tested" + + +def test_slugify_preserves_hyphenated_weapon_name(): + assert server._slugify_name("AK-47 | Redline (Field-Tested)") == "ak-47-redline-field-tested" + + +def test_slugify_glock_minimal_wear(): + assert server._slugify_name("Glock-18 | Water Elemental (Minimal Wear)") == "glock-18-water-elemental-minimal-wear" + + +def test_slugify_idempotent(): + s = "karambit-tiger-tooth-field-tested" + assert server._slugify_name(s) == s + + +# --- _slug_from_source_url --- + + +def test_slug_extracted_from_url_with_trailing_slash(): + url = "https://lis-skins.com/market/csgo/karambit-tiger-tooth-field-tested/" + assert server._slug_from_source_url(url) == "karambit-tiger-tooth-field-tested" + + +def test_slug_extracted_without_trailing_slash(): + url = "https://lis-skins.com/market/csgo/karambit-tiger-tooth-field-tested" + assert server._slug_from_source_url(url) == "karambit-tiger-tooth-field-tested" + + +def test_slug_handles_query_string(): + url = "https://lis-skins.com/market/csgo/karambit-tiger-tooth-field-tested/?ref=foo" + assert server._slug_from_source_url(url) == "karambit-tiger-tooth-field-tested" + + +def test_slug_returns_none_for_empty(): + assert server._slug_from_source_url(None) is None + assert server._slug_from_source_url("") is None + + +# --- _resolve_from_source_url (the actual fix) --- + + +def test_tiger_tooth_url_resolves_to_tiger_tooth(): + """Regression: Karambit | Tiger Tooth URL must NOT resolve to Scorched.""" + url = "https://lis-skins.com/market/csgo/karambit-tiger-tooth-field-tested/" + assert server._resolve_from_source_url(url) == "★ Karambit | Tiger Tooth (Field-Tested)" + + +def test_scorched_url_resolves_to_scorched(): + url = "https://lis-skins.com/market/csgo/karambit-scorched-field-tested/" + assert server._resolve_from_source_url(url) == "★ Karambit | Scorched (Field-Tested)" + + +def test_different_wears_resolve_independently(): + fn_url = "https://lis-skins.com/market/csgo/karambit-tiger-tooth-minimal-wear/" + assert server._resolve_from_source_url(fn_url) == "★ Karambit | Tiger Tooth (Minimal Wear)" + + +def test_stattrak_resolves_separately_from_normal(): + """StatTrak slug should not collide with normal slug.""" + normal = server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/ak-47-redline-field-tested/" + ) + stattrak = server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/stattrak-ak-47-redline-field-tested/" + ) + assert normal == "AK-47 | Redline (Field-Tested)" + assert stattrak == "StatTrak™ AK-47 | Redline (Field-Tested)" + + +def test_unknown_slug_returns_none(): + """Items not in catalog should return None — caller falls back to other resolvers.""" + url = "https://lis-skins.com/market/csgo/some-item-not-in-catalog-field-tested/" + assert server._resolve_from_source_url(url) is None + + +def test_no_url_returns_none(): + assert server._resolve_from_source_url(None) is None + assert server._resolve_from_source_url("") is None + + +def test_decoded_url_slug_matches_encoded_catalog_slug(): + """If browser decoded location.href before sending (★ instead of %E2%98%85), + resolver should still match against the percent-encoded catalog slug. + Per Codex feedback: index registers both encoded and decoded forms. + """ + items = { + "★ knife item (factory new)": { + "name": "★ Knife Item (Factory New)", + "price": 1.0, + "url": "https://lis-skins.com/market/csgo/%E2%98%85-knife-item-factory-new/", + "count": 1, + }, + } + server._prices.clear() + server._prices.update(items) + server._rebuild_slug_index() + # Encoded URL — works + assert server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/%E2%98%85-knife-item-factory-new/" + ) == "★ Knife Item (Factory New)" + # Decoded URL (literal ★) — also works + assert server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/★-knife-item-factory-new/" + ) == "★ Knife Item (Factory New)" + + +def test_resolver_uses_authoritative_url_field_when_present(): + """When item.url differs from generated slug, item.url wins (lis-skins is source of truth).""" + # Replace one item's URL with a non-canonical slug to test override + items = { + "weird item (factory new)": { + "name": "Weird Item (Factory New)", + "price": 1.0, + "url": "https://lis-skins.com/market/csgo/totally-different-slug/", + "count": 1, + }, + } + server._prices.clear() + server._prices.update(items) + server._rebuild_slug_index() + # URL slug from catalog wins + assert server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/totally-different-slug/" + ) == "Weird Item (Factory New)" + # Generated slug also works as fallback (since both got indexed) + assert server._resolve_from_source_url( + "https://lis-skins.com/market/csgo/weird-item-factory-new/" + ) == "Weird Item (Factory New)"