Files
portal/app/tests/Unit/Autopodbor/Fetch/CachingPageFetcherTest.php
T
Дмитрий 06fc4680f4 feat(autopodbor): кэш отрисованных карточек 2ГИС/Яндекс за тумблером
CachingPageFetcher — декоратор над загрузчиком карточек (SelfRenderClient/
XfetchClient): кэширует в общем Cache (Redis/file) по ключу URL на N дней ТОЛЬКО
карточки (/firm/ 2ГИС, /maps/org/ Яндекс). Поиск (/search/) и ленты — всегда мимо
кэша (свежее добывание новых фирм). Пустое не кэшируем — разовый сбой не отравит кэш.

Кэш ОБЩИЙ для шага 1 (поиск) и шага 2 (изучение+разворот) и повторных прогонов:
ключ по URL → любые экземпляры делят один кэш. Вшит в AutopodborServiceProvider
через wrapCache(); 2ГИС-база RoutingBatchFetcher и self-render card reader обёрнуты,
яндекс-Playwright/ленты — нет.

Флаг AUTOPODBOR_RENDER_CACHE (ВЫКЛ по умолчанию) + render_cache_ttl_days (5).
TDD: 8 тестов CachingPageFetcher + 4 теста проводки — зелёные. Живой замер ДСК:
тёплый прогон — 26 карточек из кэша, 0 обращений к 2ГИС/Яндексу, источников 11=11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:52:14 +03:00

150 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CachingPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
use Illuminate\Support\Facades\Cache;
// CachingPageFetcher — декоратор над реальным BatchPageFetcher. Кэширует в Redis по URL на N дней
// ТОЛЬКО карточки (/firm/ 2ГИС, /maps/org/ Яндекс). Поиск (/search/) и прочее — всегда мимо кэша.
// Пустой ответ НЕ кэшируется (разовый сбой не отравит кэш). Кэш общий для шага 1 и шага 2 (ключ по URL).
// Инструментированный inner-фетчер: считает вызовы по URL и отдаёт заготовленные ответы.
final class SpyBatchFetcher implements BatchPageFetcher
{
/** @var array<string,int> url => сколько раз спросили */
public array $htmlCalls = [];
/** @var array<string,int> url => сколько раз спросили в пачке */
public array $batchCalls = [];
/** @param array<string,string> $responses url => ответ */
public function __construct(private array $responses = []) {}
public function html(string $url): string
{
$this->htmlCalls[$url] = ($this->htmlCalls[$url] ?? 0) + 1;
return $this->responses[$url] ?? '';
}
public function htmlBatch(array $urls): array
{
$out = [];
foreach ($urls as $url) {
$this->batchCalls[$url] = ($this->batchCalls[$url] ?? 0) + 1;
$out[$url] = $this->responses[$url] ?? '';
}
return $out;
}
}
beforeEach(function () {
config()->set('cache.default', 'array');
Cache::store('array')->flush();
});
function caching(SpyBatchFetcher $inner, int $ttlDays = 5): CachingPageFetcher
{
return new CachingPageFetcher($inner, ttlDays: $ttlDays);
}
it('реализует оба интерфейса', function () {
$c = caching(new SpyBatchFetcher);
expect($c)->toBeInstanceOf(PageFetcher::class)
->and($c)->toBeInstanceOf(BatchPageFetcher::class);
});
it('карточка 2ГИС: второй вызов — из кэша (inner дёрнут один раз)', function () {
$url = 'https://2gis.ru/krasnoyarsk/firm/70000001045067758';
$inner = new SpyBatchFetcher([$url => '{"state":1}']);
$c = caching($inner);
expect($c->html($url))->toBe('{"state":1}')
->and($c->html($url))->toBe('{"state":1}');
expect($inner->htmlCalls[$url])->toBe(1);
});
it('карточка Яндекс /maps/org/ тоже кэшируется', function () {
$url = 'https://yandex.ru/maps/org/metropolis/1234567890';
$inner = new SpyBatchFetcher([$url => '<html>ya</html>']);
$c = caching($inner);
expect($c->html($url))->toBe('<html>ya</html>')
->and($c->html($url))->toBe('<html>ya</html>');
expect($inner->htmlCalls[$url])->toBe(1);
});
it('поиск /search/ — всегда мимо кэша (inner каждый раз)', function () {
$url = 'https://2gis.ru/krasnoyarsk/search/ломбард';
$inner = new SpyBatchFetcher([$url => 'SEARCH']);
$c = caching($inner);
expect($c->html($url))->toBe('SEARCH')
->and($c->html($url))->toBe('SEARCH');
expect($inner->htmlCalls[$url])->toBe(2);
});
it('пустой ответ карточки НЕ кэшируется — inner дёрнут снова', function () {
$url = 'https://2gis.ru/krasnoyarsk/firm/999';
$inner = new SpyBatchFetcher([]); // всегда пусто
$c = caching($inner);
expect($c->html($url))->toBe('')
->and($c->html($url))->toBe('');
expect($inner->htmlCalls[$url])->toBe(2);
});
it('htmlBatch: часть из кэша, часть добирается', function () {
$cached = 'https://2gis.ru/krasnoyarsk/firm/111';
$fresh = 'https://2gis.ru/krasnoyarsk/firm/222';
$inner = new SpyBatchFetcher([$cached => 'A', $fresh => 'B']);
$c = caching($inner);
// Первый заход — прогреть кэш только для $cached.
expect($c->html($cached))->toBe('A');
expect($inner->htmlCalls[$cached])->toBe(1);
// Пачка: $cached уже в кэше (inner НЕ спрашивают), $fresh добирается.
$out = $c->htmlBatch([$cached, $fresh]);
expect($out)->toBe([$cached => 'A', $fresh => 'B']);
expect($inner->batchCalls[$cached] ?? 0)->toBe(0)
->and($inner->batchCalls[$fresh])->toBe(1);
});
it('htmlBatch: /search/ всегда добирается, карточка после первого раза — из кэша', function () {
$search = 'https://2gis.ru/krasnoyarsk/search/шины';
$card = 'https://2gis.ru/krasnoyarsk/firm/333';
$inner = new SpyBatchFetcher([$search => 'S', $card => 'C']);
$c = caching($inner);
$c->htmlBatch([$search, $card]); // прогрев: card в кэш, search — нет
$out = $c->htmlBatch([$search, $card]);
// Порядок в карте url→контент не гарантирован — сверяем по ключам.
expect($out[$search])->toBe('S')->and($out[$card])->toBe('C');
// search добирался оба раза, card — только в первый.
expect($inner->batchCalls[$search])->toBe(2)
->and($inner->batchCalls[$card])->toBe(1);
});
it('htmlBatch: пустой ответ карточки не кэшируется', function () {
$card = 'https://2gis.ru/krasnoyarsk/firm/444';
$inner = new SpyBatchFetcher([]); // пусто
$c = caching($inner);
$c->htmlBatch([$card]);
$c->htmlBatch([$card]);
expect($inner->batchCalls[$card])->toBe(2);
});