botDetector = $this->createMock(BotDetector::class);
$this->cacheManager = $this->createMock(SeoCacheManagerInterface::class);
$this->renderer = $this->createMock(SeoRendererInterface::class);
$this->statsService = $this->createMock(SeoCacheStatsService::class);
$this->middleware = new SeoMiddleware(
$this->botDetector,
$this->cacheManager,
$this->renderer,
$this->statsService,
);
// 렌더·통계 예산은 IP 단위 카운터다 — 테스트 간 이월되면 순서에 따라 결과가 갈린다.
RateLimiter::clear('seo-render:127.0.0.1');
RateLimiter::clear('seo-stats:127.0.0.1');
}
/**
* SPA 폴백 응답을 반환하는 Closure를 생성합니다.
*/
private function spaNext(): \Closure
{
return fn (Request $req) => response('SPA Fallback', 200, ['Content-Type' => 'text/html']);
}
/**
* 테스트용 Request 객체를 생성합니다.
*
* @param string $path 요청 경로
* @param string $userAgent User-Agent 헤더
* @param array $query 쿼리 파라미터
*/
private function createRequest(string $path = '/products', string $userAgent = '', array $query = []): Request
{
$request = Request::create($path, 'GET', $query);
if ($userAgent !== '') {
$request->headers->set('User-Agent', $userAgent);
}
return $request;
}
// ========================================
// 봇 감지 + SEO 응답 테스트
// ========================================
/**
* Googlebot 요청 시 SEO HTML 응답을 반환하는지 검증
*/
public function test_googlebot_receives_seo_html_response(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
$expectedHtml = '
SEOProducts';
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($expectedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($expectedHtml, $response->getContent());
$this->assertStringContainsString('text/html', $response->headers->get('Content-Type'));
}
/**
* 일반 브라우저 요청 시 SPA 응답을 반환하는지 검증
*/
public function test_normal_browser_receives_spa_response(): void
{
$request = $this->createRequest('/products', 'Mozilla/5.0');
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(false);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals('SPA Fallback', $response->getContent());
}
// ========================================
// 캐시 HIT/MISS 테스트
// ========================================
/**
* 캐시 HIT 시 X-SEO-Cache: HIT 헤더와 캐시된 HTML을 반환하는지 검증
*/
public function test_cache_hit_returns_cached_html_with_hit_header(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
$cachedHtml = 'Cached SEOCached';
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn($cachedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($cachedHtml, $response->getContent());
$this->assertEquals('HIT', $response->headers->get('X-SEO-Cache'));
}
/**
* 캐시 MISS 시 새 HTML 렌더링 후 X-SEO-Cache: MISS 헤더를 반환하는지 검증
*/
public function test_cache_miss_returns_new_html_with_miss_header(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
$renderedHtml = 'Fresh SEOFresh';
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($renderedHtml, $response->getContent());
$this->assertEquals('MISS', $response->headers->get('X-SEO-Cache'));
}
/**
* 캐시 MISS 시 렌더링 결과를 캐시에 저장하는지 검증
*/
public function test_cache_miss_stores_rendered_html_in_cache(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
$renderedHtml = 'New Content';
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
$this->cacheManager->expects($this->once())
->method('putWithLayout')
->with('/products', $this->anything(), $renderedHtml, $this->anything());
$this->middleware->handle($request, $this->spaNext());
}
// ========================================
// 렌더링 실패 시 SPA 폴백 테스트
// ========================================
/**
* 렌더링 예외 발생 시 SPA 폴백을 반환하는지 검증
*/
public function test_render_exception_falls_back_to_spa(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willThrowException(new \RuntimeException('Render failed'));
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals('SPA Fallback', $response->getContent());
}
/**
* 렌더러가 null을 반환하면 SPA 폴백을 반환하는지 검증
*/
public function test_render_returns_null_falls_back_to_spa(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn(null);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals('SPA Fallback', $response->getContent());
}
// ========================================
// bot_detection_enabled 비활성화 테스트
// ========================================
/**
* bot_detection_enabled=false 시 봇 요청도 SPA 응답을 반환하는지 검증
*/
public function test_bot_detection_disabled_returns_spa_for_bot(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
config(['g7_settings.core.seo.bot_detection_enabled' => false]);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals('SPA Fallback', $response->getContent());
}
// ========================================
// Content-Type 헤더 테스트
// ========================================
/**
* SEO 응답의 Content-Type이 text/html인지 검증
*/
public function test_seo_response_has_text_html_content_type(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn('');
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertStringContainsString('text/html', $response->headers->get('Content-Type'));
$this->assertStringContainsString('charset=utf-8', $response->headers->get('Content-Type'));
}
/**
* 캐시 HIT 응답의 Content-Type이 text/html인지 검증
*/
public function test_cached_response_has_text_html_content_type(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('Cached');
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertStringContainsString('text/html', $response->headers->get('Content-Type'));
}
// ========================================
// 다국어 ?locale= 파라미터 처리 테스트
// ========================================
/**
* ?locale=en 시 영어 SEO 페이지를 반환하는지 검증
*/
public function test_locale_en_sets_app_locale_and_renders(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1', ['locale' => 'en']);
$expectedHtml = 'Products in English';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'app.locale' => 'ko',
'app.supported_locales' => ['ko', 'en'],
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($expectedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($expectedHtml, $response->getContent());
$this->assertEquals('en', app()->getLocale());
}
/**
* ?locale=ko (기본 로케일) 시 301 리다이렉트를 반환하는지 검증
*/
public function test_locale_default_redirects_to_clean_url(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1', ['locale' => 'ko']);
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'app.locale' => 'ko',
'app.supported_locales' => ['ko', 'en'],
]);
$this->botDetector->method('isBot')->willReturn(true);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(301, $response->getStatusCode());
// 리다이렉트 URL에 ?locale가 없어야 함
$location = $response->headers->get('Location');
$this->assertStringNotContainsString('locale=', $location);
}
/**
* ?locale=ja (미지원) 시 기본 로케일로 폴백하는지 검증
*/
public function test_unsupported_locale_falls_back_to_default(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1', ['locale' => 'ja']);
$expectedHtml = 'Products';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'app.locale' => 'ko',
'app.supported_locales' => ['ko', 'en'],
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($expectedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('ko', app()->getLocale());
}
/**
* ?locale 없음 시 기본 로케일로 동작하는지 검증 (기존 호환)
*/
public function test_no_locale_param_uses_default_locale(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1');
$expectedHtml = 'Products';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'app.locale' => 'ko',
'app.supported_locales' => ['ko', 'en'],
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($expectedHtml);
$response = $this->middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('ko', app()->getLocale());
}
// ========================================
// jaybizzle 라이브러리 통합 — 실제 BotDetector 사용
// ========================================
/**
* kakaotalk-scrap UA(라이브러리 미커버 → G7 보강 패턴)도 SEO 렌더링 경로로 진입하는지 검증.
* BotDetector 를 mock 없이 실제로 사용하여 라이브러리 통합이 미들웨어 단에서 동작함을 확인.
*/
public function test_kakaotalk_scrap_routed_to_seo_pipeline(): void
{
$request = $this->createRequest(
'/products',
'facebookexternalhit/1.1;kakaotalk-scrap/1.0;+https://devtalk.kakao.com/t/scrap/33984',
);
$renderedHtml = '';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'g7_settings.core.seo.bot_detection_library_enabled' => true,
'g7_settings.core.seo.bot_user_agents' => [],
]);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
// mock BotDetector 대신 컨테이너에서 실제 인스턴스를 꺼내 미들웨어 재구성.
$middleware = new SeoMiddleware(
app(BotDetector::class),
$this->cacheManager,
$this->renderer,
$this->statsService,
);
$response = $middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($renderedHtml, $response->getContent());
$this->assertStringContainsString('og:title', $response->getContent());
}
/**
* 회귀: facebookexternalhit/1.1 UA 가 봇으로 감지되어 SEO 렌더 파이프라인으로 진입.
*
* 기존 회귀: 라이브러리 통합 누락 시 페이스북이 SPA 응답을 받아 미리보기가 표시 안 됨.
*/
public function test_facebook_external_hit_routed_to_seo_pipeline(): void
{
$request = $this->createRequest('/shop/products/99', 'facebookexternalhit/1.1');
$renderedHtml = '';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'g7_settings.core.seo.bot_detection_library_enabled' => true,
'g7_settings.core.seo.bot_user_agents' => [],
]);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
$middleware = new SeoMiddleware(
app(BotDetector::class),
$this->cacheManager,
$this->renderer,
$this->statsService,
);
$response = $middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($renderedHtml, $response->getContent());
}
/**
* 회귀: Slackbot-LinkExpanding UA 가 봇으로 감지되어 SEO 렌더 파이프라인으로 진입.
*
* 기존 회귀: Slack unfurl 이 SPA 응답을 받아 미리보기 카드가 표시 안 됨.
*/
public function test_slackbot_routed_to_seo_pipeline(): void
{
$request = $this->createRequest(
'/shop/products/99',
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'
);
$renderedHtml = '';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'g7_settings.core.seo.bot_detection_library_enabled' => true,
'g7_settings.core.seo.bot_user_agents' => [],
]);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
$middleware = new SeoMiddleware(
app(BotDetector::class),
$this->cacheManager,
$this->renderer,
$this->statsService,
);
$response = $middleware->handle($request, $this->spaNext());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals($renderedHtml, $response->getContent());
}
/**
* ?locale=en 시 캐시 키에 locale이 반영되는지 검증
*/
public function test_locale_en_cache_uses_correct_locale_key(): void
{
$request = $this->createRequest('/products', 'Googlebot/2.1', ['locale' => 'en']);
$renderedHtml = 'English';
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'app.locale' => 'ko',
'app.supported_locales' => ['ko', 'en'],
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn($renderedHtml);
$this->cacheManager->expects($this->once())
->method('putWithLayout')
->with('/products', 'en', $renderedHtml, $this->anything());
$this->middleware->handle($request, $this->spaNext());
}
// ========================================
// 캐시 상한 / 렌더 예산 테스트 (KVE-2026-2191 동형)
// ========================================
/**
* IP 당 미스 렌더 예산을 넘기면 렌더도 저장도 하지 않고 SPA 를 돌려준다.
*
* 봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로
* 값만 바꾼 반복 요청이 매번 미스가 된다. 미스 1건은 레이아웃 병합·표현식 평가·자기
* API 루프백 호출을 유발하므로 요청 하나가 워커 여러 개를 묶는다.
*
* @effects bot_miss_over_limit_gets_spa_bypass
*/
public function test_bot_miss_over_render_limit_gets_spa_bypass_without_render(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.render_misses_per_minute' => 2,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->expects($this->never())->method('render');
$this->cacheManager->expects($this->never())->method('putWithLayout');
RateLimiter::hit('seo-render:127.0.0.1', 60);
RateLimiter::hit('seo-render:127.0.0.1', 60);
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1'),
$this->spaNext()
);
$this->assertSame('SPA Fallback', $response->getContent());
$this->assertSame('BYPASS', $response->headers->get('X-SEO-Cache'));
}
/**
* 예산을 넘긴 IP 라도 **캐시 적중**은 그대로 서빙한다 — 비용이 없기 때문이다.
*
* @effects bot_hit_served_regardless_of_limit
*/
public function test_cache_hit_is_served_even_when_render_limit_exceeded(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.render_misses_per_minute' => 1,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('cached');
RateLimiter::hit('seo-render:127.0.0.1', 60);
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1'),
$this->spaNext()
);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('HIT', $response->headers->get('X-SEO-Cache'));
}
/**
* `_escaped_fragment_` 는 봇 렌더 요청 표식일 뿐이라 캐시 키를 가르지 않는다.
*
* @effects system_query_params_excluded_from_key
*/
public function test_escaped_fragment_param_does_not_change_cache_key(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$seen = [];
$this->cacheManager->method('get')->willReturnCallback(function (string $url) use (&$seen) {
$seen[] = $url;
return 'cached';
});
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1', ['_escaped_fragment_' => '']),
$this->spaNext()
);
$this->assertCount(2, $seen);
$this->assertSame($seen[0], $seen[1]);
}
/**
* 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다 — 캐시 조회도 렌더도 하지 않는다.
*
* @effects oversized_query_bypasses_cache_and_render
*/
public function test_oversized_query_bypasses_cache_and_render(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.max_query_params' => 10,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->expects($this->never())->method('get');
$this->renderer->expects($this->never())->method('render');
$query = [];
for ($i = 0; $i < 11; $i++) {
$query['p'.$i] = '1';
}
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1', $query),
$this->spaNext()
);
$this->assertSame('SPA Fallback', $response->getContent());
$this->assertSame('BYPASS', $response->headers->get('X-SEO-Cache'));
}
/**
* 캐시 적중·미적중이 통계에 기록된다.
*
* 기록 호출처가 없으면 `seo:stats` 와 관리자 통계가 항상 0 이라, 공격이 진행돼도
* 운영자 화면은 아무것도 달라지지 않는다.
*
* @effects cache_hit_and_miss_are_recorded_in_stats
*/
public function test_cache_hit_records_stat_hit(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('cached');
$this->statsService->expects($this->once())->method('recordHit');
$this->statsService->expects($this->never())->method('recordMiss');
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
}
/**
* @effects cache_hit_and_miss_are_recorded_in_stats
*/
public function test_cache_miss_records_stat_miss_with_response_time(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn('rendered');
$this->statsService->expects($this->once())
->method('recordMiss')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->greaterThanOrEqual(0)
);
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
}
/**
* 통계 기록도 IP 당 상한을 넘기면 멈춘다 — 통계 테이블이 새 증식 축이 되면 안 된다.
*
* @effects stats_recording_capped_per_ip
*/
public function test_stats_recording_is_capped_per_ip(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.stats_records_per_minute' => 1,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('cached');
RateLimiter::hit('seo-stats:127.0.0.1', 60);
$this->statsService->expects($this->never())->method('recordHit');
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
}
/**
* 캐시 적중(HIT)은 렌더러를 거치지 않으므로 레이아웃명을 요청 속성에서 얻을 수 없다 —
* 캐시 항목에 함께 저장된 레이아웃명으로 통계에 귀속한다. 없으면 화면별 표에서 모든
* 화면의 적중이 0 이 되고 'N/A' 행에만 쌓인다.
*
* @effects cache_hit_carries_layout_name_from_cache_entry
*/
public function test_cache_hit_records_stat_hit_with_layout_from_cache_entry(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$cacheManager = $this->createMock(SeoCacheManager::class);
$cacheManager->method('getEntry')->willReturn(['html' => 'cached', 'layout' => 'shop/show']);
$middleware = new SeoMiddleware($this->botDetector, $cacheManager, $this->renderer, $this->statsService);
$this->botDetector->method('isBot')->willReturn(true);
$this->statsService->expects($this->once())
->method('recordHit')
->with('/products', config('app.locale'), 'shop/show');
$response = $middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame('cached', $response->getContent());
$this->assertSame('HIT', $response->headers->get('X-SEO-Cache'));
}
/**
* 렌더러가 "그릴 게 없음"(null)을 돌려주면 방금 뺀 렌더 예산을 되돌린다 — 미라우트 404 나
* SEO 비활성 화면은 캐시에 남지 않아 올 때마다 다시 예산을 쓰므로, 죽은 주소 재크롤이
* 정상 페이지의 예산을 태운다.
*
* @effects null_render_refunds_render_budget
*/
public function test_null_render_refunds_render_budget(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn(null);
$this->middleware->handle($this->createRequest('/gone', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame(0, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
}
/**
* 렌더 중 예외는 비용을 이미 치른 것이므로 예산을 되돌리지 않는다 (회귀 가드).
*
* @effects null_render_refunds_render_budget
*/
public function test_render_exception_keeps_render_budget_charged(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willThrowException(new \RuntimeException('boom'));
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame(1, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
}
}