All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно одно — page.goto(url), — и навигацией браузера на API-хост мы делали то, чего настоящий клиент не делает никогда. Перехват сети на живой выдаче 30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят XHR'ы за гео, районами и метро. Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout: прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0 лотов. Карточки через те же узлы в те же минуты шли. Замер трёх режимов на одном узле, один и тот же ресурс: BFF navigate ChallengeTimeout, 55с BFF subresource HTTP 200, 52 байта, 6с BFF page_fetch ошибка Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно 6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение — дрейф фонда за пару часов); список офферов отдаётся на всех четырёх узлах пула, по 20 штук, 120 КБ. Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение), subresource (context.request.get из прогретого контекста) и page_fetch (fetch из страницы). Третий оставлен, потому что теоретически он ближе всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает в куки контекста именно при заходе на страницу, поэтому свип теперь передаёт origin и referer, которых раньше не передавал вовсе. _decode_body распаковывает gzip по магическим байтам — карта офферов приезжает Content-Type: application/gzip, и без этого вызывающий получил бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против 62% у карты. fetch_mode кладётся в payload только когда он не дефолтный — сайдкар прежней версии не должен получать незнакомый ключ (тот же приём, что с referer в #3247). Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6 проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py — 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру — обновлены.
112 lines
4.4 KiB
Python
112 lines
4.4 KiB
Python
"""test_server_fetch_mode.py — режимы получения тела: навигация против подзапроса (#3264).
|
||
|
||
BFF-ручка Домклика и карта офферов страницами не являются. Навигацией браузера на
|
||
BFF через мобильные узлы пула приходит только ChallengeTimeout, подзапросом из
|
||
прогретого контекста — HTTP 200 за 6 секунд (замер 30.08.2026, четыре узла, шесть
|
||
корзин). Здесь проверяется контракт режимов, а не поведение площадки.
|
||
|
||
Тестируется:
|
||
1. дефолт остаётся навигацией — goto зовётся, подзапрос нет;
|
||
2. subresource НЕ навигирует на целевой url и берёт тело запросом контекста;
|
||
3. gzip-файл распаковывается (карта офферов приезжает application/gzip);
|
||
4. неизвестный режим отбивается 400, а не падает внутри.
|
||
|
||
camoufox НЕ запускается — browser/context/page подделаны.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import gzip
|
||
import importlib.util
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||
assert _spec is not None and _spec.loader is not None
|
||
server = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(server)
|
||
|
||
|
||
class _Resp:
|
||
def __init__(self, raw: bytes, status: int = 200) -> None:
|
||
self._raw, self.status = raw, status
|
||
|
||
async def body(self) -> bytes:
|
||
return self._raw
|
||
|
||
|
||
class _Request:
|
||
def __init__(self, raw: bytes) -> None:
|
||
self._raw = raw
|
||
self.calls: list[tuple[str, Any]] = []
|
||
|
||
async def get(self, url: str, **kwargs: Any) -> _Resp:
|
||
self.calls.append((url, kwargs))
|
||
return _Resp(self._raw)
|
||
|
||
|
||
class _Context:
|
||
def __init__(self, raw: bytes) -> None:
|
||
self.request = _Request(raw)
|
||
|
||
|
||
class _Page:
|
||
def __init__(self, raw: bytes = b"{}") -> None:
|
||
self.context = _Context(raw)
|
||
self.goto_urls: list[str] = []
|
||
self.evaluated: list[str] = []
|
||
|
||
async def goto(self, url: str, **_kw: Any) -> None:
|
||
self.goto_urls.append(url)
|
||
|
||
async def evaluate(self, _script: str, url: str) -> dict:
|
||
self.evaluated.append(url)
|
||
return {"status": 200, "b64": ""}
|
||
|
||
|
||
def test_gzip_body_is_decompressed() -> None:
|
||
"""Карта офферов приезжает Content-Type: application/gzip — стек её не распакует."""
|
||
raw = gzip.compress("<urlset><loc>x</loc></urlset>".encode())
|
||
assert server._decode_body(raw) == "<urlset><loc>x</loc></urlset>"
|
||
|
||
|
||
def test_plain_body_passes_through() -> None:
|
||
assert server._decode_body(b'{"result":{"offersCount":2202}}') == (
|
||
'{"result":{"offersCount":2202}}'
|
||
)
|
||
|
||
|
||
def test_subresource_does_not_navigate_to_target() -> None:
|
||
"""Смысл режима: на целевой url НЕ ходим навигацией, берём его подзапросом."""
|
||
page = _Page(b'{"result":{"offersCount":2202}}')
|
||
raw, status = asyncio.run(
|
||
server._fetch_subresource(page, "https://bff-search-web.domclick.ru/api/x",
|
||
"subresource", "https://ekaterinburg.domclick.ru/serp")
|
||
)
|
||
assert page.goto_urls == []
|
||
assert status == 200
|
||
assert b"offersCount" in raw
|
||
url, kwargs = page.context.request.calls[0]
|
||
assert url == "https://bff-search-web.domclick.ru/api/x"
|
||
assert kwargs["headers"] == {"Referer": "https://ekaterinburg.domclick.ru/serp"}
|
||
|
||
|
||
def test_subresource_without_referer_sends_no_header() -> None:
|
||
page = _Page()
|
||
asyncio.run(server._fetch_subresource(page, "https://x.domclick.ru/api", "subresource", None))
|
||
assert page.context.request.calls[0][1]["headers"] is None
|
||
|
||
|
||
def test_unknown_mode_raises_named_error() -> None:
|
||
page = _Page()
|
||
with pytest.raises(ValueError, match="fetch_mode"):
|
||
asyncio.run(server._fetch_subresource(page, "https://x/api", "телепортация", None))
|
||
|
||
|
||
def test_navigate_is_the_default_and_stays_a_valid_mode() -> None:
|
||
assert "navigate" in server.FETCH_MODES
|
||
assert {"navigate", "subresource", "page_fetch"} == set(server.FETCH_MODES)
|