gendesign/tradein-mvp/browser/test_server_fetch_mode.py
bot-backend 5baf07d6e6
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
fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264)
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 знали старую сигнатуру
— обновлены.
2026-08-30 11:24:28 +03:00

112 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)