공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다. 응답·행의 무게를 다룬 · 과 겹치지 않는다. 공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로 정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은 계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다. 계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면 그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다. 응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새 필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate 응답은 필드 단위로 이전과 동일하다. 요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일 재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어 화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한 변경이 종전처럼 다음 요청에 반영된다. 검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한 엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어 활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩 대신 선언형 config 로 옮겼다. 총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을 돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
153 lines
5.3 KiB
PHP
153 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Search;
|
|
|
|
use App\Enums\TotalRelation;
|
|
use App\Models\User;
|
|
use App\Support\Query\BoundedPaginator;
|
|
use App\Support\Query\PaginationLimits;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 검색·목록 총 건수 정확도 계약 테스트
|
|
*
|
|
* 화면이 "1,234건" 과 "10,000건 이상" 을 구분해 말할 수 있으려면, 서버가 그 구분을
|
|
* 응답에 실어 보내야 한다. 세지 않은 값을 정확한 것처럼 말하지 않는다.
|
|
*
|
|
* @scenario pagination-accuracy-contract
|
|
*
|
|
* @effects exact_total_reports_exact,
|
|
* bounded_total_reports_at_least,
|
|
* bounded_total_hides_last_page,
|
|
* bounded_total_keeps_next,
|
|
* page_limit_rejects_abusive_page,
|
|
* search_message_switches_on_accuracy
|
|
*/
|
|
class SearchAccuracyContractTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 상한 이하면 총 건수가 정확하고 마지막 페이지가 계산되는지 확인
|
|
*
|
|
* @effects exact_total_reports_exact
|
|
*/
|
|
public function test_exact_total_reports_exact_and_last_page(): void
|
|
{
|
|
User::factory()->count(7)->create();
|
|
|
|
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 100);
|
|
|
|
$this->assertSame(TotalRelation::Exact, $page->totalRelation());
|
|
$this->assertTrue($page->totalRelation()->isExact());
|
|
$this->assertSame(User::query()->count(), $page->total());
|
|
$this->assertNotNull($page->lastPage(), '정확한 총 건수에서는 마지막 페이지가 계산된다');
|
|
}
|
|
|
|
/**
|
|
* 상한 초과 시 하한으로 보고하고 마지막 페이지를 비우는지 확인
|
|
*
|
|
* @effects bounded_total_reports_at_least, bounded_total_hides_last_page
|
|
*/
|
|
public function test_bounded_total_reports_at_least_and_hides_last_page(): void
|
|
{
|
|
User::factory()->count(12)->create();
|
|
|
|
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 5);
|
|
|
|
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
|
|
$this->assertFalse($page->totalRelation()->isExact());
|
|
$this->assertSame(5, $page->total(), '상한을 넘으면 상한값을 하한으로 보고한다');
|
|
$this->assertNull($page->lastPage(), '마지막 페이지는 계산할 수 없다');
|
|
}
|
|
|
|
/**
|
|
* 상한을 넘겨도 "다음" 이동이 끝까지 열려 있는지 확인
|
|
*
|
|
* @effects bounded_total_keeps_next
|
|
*/
|
|
public function test_bounded_total_keeps_next_navigation_open(): void
|
|
{
|
|
User::factory()->count(12)->create();
|
|
|
|
$reached = [];
|
|
|
|
for ($pageNumber = 1; $pageNumber <= 5; $pageNumber++) {
|
|
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: $pageNumber, resultCap: 5);
|
|
|
|
foreach ($page->items() as $user) {
|
|
$reached[] = $user->id;
|
|
}
|
|
|
|
if (! $page->hasMorePages()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
User::query()->orderBy('id')->pluck('id')->all(),
|
|
collect($reached)->sort()->values()->all(),
|
|
'상한과 무관하게 마지막 행까지 도달할 수 있어야 한다'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 통합검색 응답이 정확도 메타를 함께 내보내는지 확인
|
|
*
|
|
* @effects search_message_switches_on_accuracy
|
|
*/
|
|
public function test_search_response_carries_accuracy_meta(): void
|
|
{
|
|
$response = $this->getJson('/api/search?'.http_build_query(['q' => '검색어테스트']));
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$data = $response->json('data');
|
|
|
|
$this->assertArrayHasKey('total_relation', $data, '정확도를 응답에 실어야 화면이 문구를 고를 수 있다');
|
|
$this->assertArrayHasKey('total_is_exact', $data);
|
|
$this->assertArrayHasKey('result_cap', $data);
|
|
$this->assertSame(TotalRelation::Exact->value, $data['total_relation']);
|
|
$this->assertTrue($data['total_is_exact']);
|
|
}
|
|
|
|
/**
|
|
* 남용 방지용 페이지 상한이 적용되는지 확인
|
|
*
|
|
* 정상 탐색은 has_more_pages 로 열려 있고, 상한은 임의의 큰 페이지 번호를 직접
|
|
* 던져 초대형 OFFSET 을 만드는 것만 막는다.
|
|
*
|
|
* @effects page_limit_rejects_abusive_page
|
|
*/
|
|
public function test_page_number_upper_bound_is_enforced(): void
|
|
{
|
|
config(['g7_settings.core.pagination.max_page' => 50]);
|
|
|
|
$maxPage = PaginationLimits::maxPage('search');
|
|
$this->assertSame(50, $maxPage);
|
|
|
|
$response = $this->getJson('/api/search?'.http_build_query(['q' => '검색어테스트', 'page' => 999999]));
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['page']);
|
|
}
|
|
|
|
/**
|
|
* 상한이 0 이면 무제한으로 해석되는지 확인
|
|
*/
|
|
public function test_zero_cap_means_unlimited(): void
|
|
{
|
|
config(['g7_settings.core.pagination.result_cap' => 0]);
|
|
|
|
$this->assertNull(PaginationLimits::resultCap('search'));
|
|
|
|
User::factory()->count(4)->create();
|
|
|
|
[$total, $relation] = BoundedPaginator::countWithCap(User::query(), PaginationLimits::resultCap('search'));
|
|
|
|
$this->assertSame(User::query()->count(), $total);
|
|
$this->assertSame(TotalRelation::Exact, $relation);
|
|
}
|
|
}
|