KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함 5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그 이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다. - 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고 코어 구현일 때만 항목 전체를 읽는다. - 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키 상한 3072바이트). down 은 잘리는 행을 먼저 지운다. - 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 → 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다. - 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중 예외는 비용을 이미 치른 것이라 환급하지 않는다. - 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라 종전 per-extension warning 은 기록되지 않아 흔적이 없었다. 회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트 클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
303 lines
12 KiB
PHP
303 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Models\SeoCacheStat;
|
|
use App\Seo\SeoCacheStatsService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\TestCase;
|
|
|
|
class SeoCacheStatsServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private SeoCacheStatsService $statsService;
|
|
|
|
/**
|
|
* 테스트 초기화 - SeoCacheStatsService 인스턴스를 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Repository 주입이 필요하므로 컨테이너로 해석합니다.
|
|
$this->statsService = $this->app->make(SeoCacheStatsService::class);
|
|
}
|
|
|
|
/**
|
|
* recordHit이 type='hit'인 레코드를 생성합니다.
|
|
*/
|
|
public function test_record_hit_creates_hit_record(): void
|
|
{
|
|
$this->statsService->recordHit('/products/1', 'ko', 'shop/show', 'sirsoft-ecommerce');
|
|
|
|
$this->assertDatabaseHas('seo_cache_stats', [
|
|
'url' => '/products/1',
|
|
'locale' => 'ko',
|
|
'layout_name' => 'shop/show',
|
|
'module_identifier' => 'sirsoft-ecommerce',
|
|
'type' => 'hit',
|
|
'response_time_ms' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* recordMiss가 type='miss'이고 response_time_ms를 포함한 레코드를 생성합니다.
|
|
*/
|
|
public function test_record_miss_creates_miss_record_with_response_time(): void
|
|
{
|
|
$this->statsService->recordMiss('/products/1', 'ko', 'shop/show', 'sirsoft-ecommerce', 150);
|
|
|
|
$this->assertDatabaseHas('seo_cache_stats', [
|
|
'url' => '/products/1',
|
|
'locale' => 'ko',
|
|
'layout_name' => 'shop/show',
|
|
'module_identifier' => 'sirsoft-ecommerce',
|
|
'type' => 'miss',
|
|
'response_time_ms' => 150,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* recordHit에서 예외 발생 시 Log::warning으로 처리됩니다.
|
|
*/
|
|
public function test_record_hit_handles_exception_gracefully(): void
|
|
{
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->withArgs(function (string $message, array $context) {
|
|
return str_contains($message, '캐시 히트 기록 실패')
|
|
&& $context['url'] === '/products/1';
|
|
});
|
|
|
|
// 테이블을 삭제하여 DB 예외를 강제 발생
|
|
Schema::drop('seo_cache_stats');
|
|
|
|
// 예외가 전파되지 않음을 확인
|
|
$this->statsService->recordHit('/products/1', 'ko');
|
|
}
|
|
|
|
/**
|
|
* recordMiss에서 예외 발생 시 Log::warning으로 처리됩니다.
|
|
*/
|
|
public function test_record_miss_handles_exception_gracefully(): void
|
|
{
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->withArgs(function (string $message, array $context) {
|
|
return str_contains($message, '캐시 미스 기록 실패')
|
|
&& $context['url'] === '/products/1';
|
|
});
|
|
|
|
// 테이블을 삭제하여 DB 예외를 강제 발생
|
|
Schema::drop('seo_cache_stats');
|
|
|
|
// 예외가 전파되지 않음을 확인
|
|
$this->statsService->recordMiss('/products/1', 'ko', null, null, 200);
|
|
}
|
|
|
|
/**
|
|
* 데이터가 없을 때 getStats가 기본값을 반환합니다.
|
|
*/
|
|
public function test_get_stats_returns_defaults_with_no_data(): void
|
|
{
|
|
$stats = $this->statsService->getStats();
|
|
|
|
$this->assertSame(0, $stats['total_entries']);
|
|
$this->assertSame(0, $stats['hits']);
|
|
$this->assertSame(0, $stats['misses']);
|
|
$this->assertSame(0.0, $stats['hit_rate']);
|
|
$this->assertNull($stats['avg_response_time_ms']);
|
|
}
|
|
|
|
/**
|
|
* getStats가 올바른 히트율을 계산합니다.
|
|
*/
|
|
public function test_get_stats_returns_correct_hit_rate(): void
|
|
{
|
|
// 3개 히트, 2개 미스 = 60% 히트율
|
|
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
|
|
SeoCacheStat::create(['url' => '/p/5', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 200]);
|
|
|
|
$stats = $this->statsService->getStats();
|
|
|
|
$this->assertSame(5, $stats['total_entries']);
|
|
$this->assertSame(3, $stats['hits']);
|
|
$this->assertSame(2, $stats['misses']);
|
|
$this->assertSame(60.0, $stats['hit_rate']);
|
|
}
|
|
|
|
/**
|
|
* getStats가 since 파라미터로 기간 필터링합니다.
|
|
*/
|
|
public function test_get_stats_filters_by_since_date(): void
|
|
{
|
|
// 오래된 레코드 (5일 전)
|
|
$old = SeoCacheStat::create(['url' => '/old', 'locale' => 'ko', 'type' => 'hit']);
|
|
$old->created_at = Carbon::now()->subDays(5);
|
|
$old->save();
|
|
|
|
// 최근 레코드 (1일 전)
|
|
$recent = SeoCacheStat::create(['url' => '/recent', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
|
|
$recent->created_at = Carbon::now()->subDays(1);
|
|
$recent->save();
|
|
|
|
// 3일 이내 데이터만 조회
|
|
$stats = $this->statsService->getStats(Carbon::now()->subDays(3));
|
|
|
|
$this->assertSame(1, $stats['total_entries']);
|
|
$this->assertSame(0, $stats['hits']);
|
|
$this->assertSame(1, $stats['misses']);
|
|
}
|
|
|
|
/**
|
|
* getStats가 미스 레코드의 평균 응답 시간을 계산합니다.
|
|
*/
|
|
public function test_get_stats_calculates_avg_response_time_from_misses_only(): void
|
|
{
|
|
// 히트 (response_time_ms 없음)
|
|
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'type' => 'hit']);
|
|
|
|
// 미스 (response_time_ms 있음) - 평균 = (100 + 300) / 2 = 200
|
|
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
|
|
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 300]);
|
|
|
|
// 미스이지만 response_time_ms가 null
|
|
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'type' => 'miss']);
|
|
|
|
$stats = $this->statsService->getStats();
|
|
|
|
$this->assertSame(4, $stats['total_entries']);
|
|
$this->assertSame(200.0, $stats['avg_response_time_ms']);
|
|
}
|
|
|
|
/**
|
|
* getStatsByLayout이 레이아웃별로 통계를 그룹화합니다.
|
|
*/
|
|
public function test_get_stats_by_layout_groups_by_layout_name(): void
|
|
{
|
|
// shop/show 레이아웃: 2히트, 1미스
|
|
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'miss', 'response_time_ms' => 150]);
|
|
|
|
// shop/category 레이아웃: 1히트, 1미스
|
|
SeoCacheStat::create(['url' => '/c/1', 'locale' => 'ko', 'layout_name' => 'shop/category', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/c/2', 'locale' => 'ko', 'layout_name' => 'shop/category', 'type' => 'miss', 'response_time_ms' => 200]);
|
|
|
|
$result = $this->statsService->getStatsByLayout();
|
|
|
|
$this->assertCount(2, $result);
|
|
|
|
// layout_name 기준으로 정렬하여 검증
|
|
$byLayout = collect($result)->keyBy('layout_name');
|
|
|
|
$showStats = $byLayout['shop/show'];
|
|
$this->assertSame(3, $showStats['total']);
|
|
$this->assertSame(2, $showStats['hits']);
|
|
$this->assertSame(1, $showStats['misses']);
|
|
$this->assertSame(66.67, $showStats['hit_rate']);
|
|
$this->assertSame(150.0, $showStats['avg_response_time_ms']);
|
|
|
|
$categoryStats = $byLayout['shop/category'];
|
|
$this->assertSame(2, $categoryStats['total']);
|
|
$this->assertSame(1, $categoryStats['hits']);
|
|
$this->assertSame(1, $categoryStats['misses']);
|
|
$this->assertSame(50.0, $categoryStats['hit_rate']);
|
|
$this->assertSame(200.0, $categoryStats['avg_response_time_ms']);
|
|
}
|
|
|
|
/**
|
|
* getStatsByModule이 모듈별로 통계를 그룹화합니다.
|
|
*/
|
|
public function test_get_stats_by_module_groups_by_module_identifier(): void
|
|
{
|
|
// sirsoft-ecommerce 모듈: 3히트, 1미스
|
|
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'miss', 'response_time_ms' => 250]);
|
|
|
|
// sirsoft-blog 모듈: 1히트, 2미스
|
|
SeoCacheStat::create(['url' => '/b/1', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'hit']);
|
|
SeoCacheStat::create(['url' => '/b/2', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'miss', 'response_time_ms' => 100]);
|
|
SeoCacheStat::create(['url' => '/b/3', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'miss', 'response_time_ms' => 300]);
|
|
|
|
$result = $this->statsService->getStatsByModule();
|
|
|
|
$this->assertCount(2, $result);
|
|
|
|
$byModule = collect($result)->keyBy('module_identifier');
|
|
|
|
$ecommerceStats = $byModule['sirsoft-ecommerce'];
|
|
$this->assertSame(4, $ecommerceStats['total']);
|
|
$this->assertSame(3, $ecommerceStats['hits']);
|
|
$this->assertSame(1, $ecommerceStats['misses']);
|
|
$this->assertSame(75.0, $ecommerceStats['hit_rate']);
|
|
$this->assertSame(250.0, $ecommerceStats['avg_response_time_ms']);
|
|
|
|
$blogStats = $byModule['sirsoft-blog'];
|
|
$this->assertSame(3, $blogStats['total']);
|
|
$this->assertSame(1, $blogStats['hits']);
|
|
$this->assertSame(2, $blogStats['misses']);
|
|
$this->assertSame(33.33, $blogStats['hit_rate']);
|
|
$this->assertSame(200.0, $blogStats['avg_response_time_ms']);
|
|
}
|
|
|
|
/**
|
|
* cleanup이 오래된 레코드를 삭제하고 최근 레코드를 유지합니다.
|
|
*/
|
|
public function test_cleanup_deletes_old_records_and_keeps_recent(): void
|
|
{
|
|
// 오래된 레코드 (40일 전)
|
|
$old1 = SeoCacheStat::create(['url' => '/old/1', 'locale' => 'ko', 'type' => 'hit']);
|
|
$old1->created_at = Carbon::now()->subDays(40);
|
|
$old1->save();
|
|
|
|
$old2 = SeoCacheStat::create(['url' => '/old/2', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
|
|
$old2->created_at = Carbon::now()->subDays(35);
|
|
$old2->save();
|
|
|
|
// 최근 레코드 (10일 전)
|
|
$recent1 = SeoCacheStat::create(['url' => '/recent/1', 'locale' => 'ko', 'type' => 'hit']);
|
|
$recent1->created_at = Carbon::now()->subDays(10);
|
|
$recent1->save();
|
|
|
|
// 오늘 레코드
|
|
SeoCacheStat::create(['url' => '/today/1', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 200]);
|
|
|
|
$deleted = $this->statsService->cleanup(30);
|
|
|
|
$this->assertSame(2, $deleted);
|
|
$this->assertDatabaseCount('seo_cache_stats', 2);
|
|
$this->assertDatabaseMissing('seo_cache_stats', ['url' => '/old/1']);
|
|
$this->assertDatabaseMissing('seo_cache_stats', ['url' => '/old/2']);
|
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']);
|
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/today/1']);
|
|
}
|
|
|
|
/**
|
|
* 통계의 url 컬럼은 캐시 키 URL(경로 + 정규화 쿼리 최대 512바이트)을 그대로 담을 수 있어야
|
|
* 한다 — 짧으면 긴 주소의 기록이 조용히 실패한다(예외는 서비스가 삼키고 warning 만 남긴다).
|
|
*
|
|
* @effects stats_url_column_fits_normalized_cache_url
|
|
*/
|
|
public function test_record_persists_url_as_long_as_the_cache_url_bound(): void
|
|
{
|
|
$url = '/shop/products?'.str_repeat('filters[a]=0123456789&', 24).'page=2';
|
|
|
|
$this->assertGreaterThan(255, strlen($url));
|
|
$this->assertLessThanOrEqual(768, strlen($url));
|
|
|
|
$this->statsService->recordHit($url, 'ko', 'shop/index');
|
|
|
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => $url, 'type' => 'hit']);
|
|
}
|
|
}
|