7.0.6 사이클에서 유입된 회귀 3건을 출시 전에 닫는다. 추가옵션 저장소의 반환 타입 선언이 실제 반환(중첩 맵)과 어긋나 추가옵션 있는 상품의 담기· 바로구매가 죽었고, 검색 엔진이 결과 소비 시점에 SELECT 를 재작성해 정렬이 참조하던 스코어 별칭을 지웠으며, 미들웨어 targets 가 늘어난 라우트를 따라가지 못해 비회원 현금영수증이 전면 불능이었다. 셋 다 정적 검사의 사각지대였으므로 룰 2건과 계약 테스트를 함께 세워 재발을 막는다. 같은 유형의 잠복 결함 3건(빈 반환의 타입 불일치 2건, 없는 메서드 호출 1건)도 호출 경로가 생기는 순간 fatal 이므로 함께 닫았다. 브라우저 검수 중 드러난 선행 결함 5건을 이어서 수정한다. 엔진이 event 키로 선언한 액션을 React prop 으로 연결하지 않아 오류 없이 조용히 무반응이었고, 연결한 뒤에는 커스텀 컴포넌트의 이벤트 payload 가 유실됐다. 비회원 주문상세 응답에 현금영수증 키가 없어 발급 결과에 도달할 수 없었으며, 입금확인과 현금영수증 발급이 base 통화 금액을 써서 결제 통화가 다른 주문에서 각각 저장 실패와 잘못된 증빙을 냈다. 마지막으로 통화 단위를 코드·문구에서 걷어내 설정으로 이관한다. 다국어 문구의 원/円, 프론트의 KRW 고정표와 리터럴 폴백 173건, 서버 포맷의 통화 코드 리터럴이 대상이며, 기본 통화가 원이 아닌 상점에서 값은 맞고 단위만 틀린 금액이 예외 없이 나가던 문제였다. 통화 판정을 설정 단일 출처로 모으고 규칙 3건을 정적 검사로 고정했다.
228 lines
8.0 KiB
PHP
228 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit\Search;
|
|
|
|
use App\Search\Contracts\FulltextSearchable;
|
|
use App\Search\Engines\DatabaseFulltextEngine;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Laravel\Scout\Builder;
|
|
use Laravel\Scout\Searchable;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `mapIds()` 가 넘겨받은 쿼리의 SELECT 를 재작성하지 않는지 검증.
|
|
*
|
|
* 회귀 배경: `mapIds()` 가 `clone()->select($key)` 로 SELECT 를 통째로 대체해
|
|
* `selectRaw` 로 붙은 `(...) as _ft_score` 를 지웠다. clone 이 보존한
|
|
* `ORDER BY _ft_score DESC` 는 남아 `SQLSTATE[42S22] Unknown column '_ft_score'
|
|
* in 'order clause'` 가 됐다. `queryCallback` 이 있을 때만 도달하므로
|
|
* `->query(fn)` 을 쓰는 Scout `paginate()` 경로(관리자 상품 공통정보 목록 검색)가 500 이었다.
|
|
*
|
|
* 이 클래스가 기존 `DatabaseFulltextEngineTest` 와 분리된 이유: 그쪽은 SQL 을 실행하지 않는
|
|
* 빠른 단위 테스트이고, 그 클래스의 `mapIds` 케이스는 `['query' => null]` 만 넣어 early return
|
|
* 만 타므로 수정 전후 모두 green 이라 증빙이 되지 못한다. 여기서는 실제 FULLTEXT 인덱스를
|
|
* 만들고 SQL 을 실행한다.
|
|
*/
|
|
class DatabaseFulltextEngineMapIdsTest extends TestCase
|
|
{
|
|
/**
|
|
* 프리픽스 미포함 테이블명 (`DB::getTablePrefix()` 가 붙어 실제 테이블이 된다).
|
|
*/
|
|
private const TABLE = 'ft_engine_fixtures';
|
|
|
|
private DatabaseFulltextEngine $engine;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
if (! DatabaseFulltextEngine::supportsFulltext()) {
|
|
$this->markTestSkipped('FULLTEXT 를 제공하지 않는 DBMS 입니다.');
|
|
}
|
|
|
|
$this->engine = new DatabaseFulltextEngine;
|
|
|
|
Schema::dropIfExists(self::TABLE);
|
|
Schema::create(self::TABLE, function ($table): void {
|
|
$table->id();
|
|
$table->string('name');
|
|
$table->text('description');
|
|
$table->unsignedInteger('sort_order')->default(0);
|
|
});
|
|
|
|
// 엔진은 컬럼마다 별도 MATCH() 를 만들고, MySQL 은 MATCH 의 컬럼 목록과 정확히
|
|
// 일치하는 FULLTEXT 인덱스를 요구한다 → 복합 인덱스가 아니라 컬럼별 인덱스여야 한다.
|
|
// 실제 마이그레이션과 동일하게 코어 헬퍼를 써서 ngram 파서 부착까지 일치시킨다.
|
|
DatabaseFulltextEngine::addFulltextIndex(self::TABLE, 'ft_'.self::TABLE.'_name', 'name');
|
|
DatabaseFulltextEngine::addFulltextIndex(self::TABLE, 'ft_'.self::TABLE.'_description', 'description');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Schema::dropIfExists(self::TABLE);
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 픽스처 행을 저장합니다.
|
|
*
|
|
* @param string $name 이름
|
|
* @param string $description 설명
|
|
* @param int $sortOrder 정렬 순서
|
|
* @return int 생성된 행 ID
|
|
*/
|
|
private function seedRow(string $name, string $description, int $sortOrder = 0): int
|
|
{
|
|
return (int) DB::table(self::TABLE)->insertGetId([
|
|
'name' => $name,
|
|
'description' => $description,
|
|
'sort_order' => $sortOrder,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 검색 빌더를 만듭니다.
|
|
*
|
|
* @param string $keyword 검색어
|
|
* @param callable|null $queryCallback 소비자 쿼리 콜백
|
|
* @return Builder Scout 빌더
|
|
*/
|
|
private function builder(string $keyword, ?callable $queryCallback = null): Builder
|
|
{
|
|
$builder = new Builder(new FulltextEngineFixture, $keyword);
|
|
|
|
if ($queryCallback !== null) {
|
|
$builder->query($queryCallback);
|
|
}
|
|
|
|
return $builder;
|
|
}
|
|
|
|
/**
|
|
* 소비자 정렬이 걸린 페이지네이션 쿼리에서 mapIds 가 살아남는지.
|
|
*
|
|
* 수정 전에는 `QueryException 42S22 Unknown column '_ft_score' in 'order clause'`.
|
|
*/
|
|
public function test_map_ids_survives_score_order_by_on_paginated_query(): void
|
|
{
|
|
$first = $this->seedRow('검색대상 문서', '본문 내용', 1);
|
|
$second = $this->seedRow('검색대상 안내', '본문 내용', 2);
|
|
|
|
$builder = $this->builder('검색대상', fn ($query) => $query->orderBy('sort_order'));
|
|
|
|
$results = $this->engine->paginate($builder, 20, 1);
|
|
$ids = $this->engine->mapIds($results);
|
|
|
|
$this->assertEqualsCanonicalizing([$first, $second], $ids->all());
|
|
}
|
|
|
|
/**
|
|
* 소비자 콜백이 얹은 **다른** 별칭에 정렬이 걸려도 살아남는지.
|
|
*
|
|
* "스코어 SELECT 만 보존해 재조립" 안을 기각한 이유를 고정한다 — 알려진 별칭 하나만
|
|
* 막으면 `withCount()` 의 `*_count` 같은 평범한 별칭 정렬에서 똑같이 재발한다.
|
|
*/
|
|
public function test_map_ids_survives_consumer_added_order_by_alias(): void
|
|
{
|
|
$this->seedRow('검색대상 짧은', '본문 내용');
|
|
$this->seedRow('검색대상 아주아주 긴 이름', '본문 내용');
|
|
|
|
$builder = $this->builder(
|
|
'검색대상',
|
|
fn ($query) => $query->selectRaw('CHAR_LENGTH(name) as _len')->orderByDesc('_len'),
|
|
);
|
|
|
|
$results = $this->engine->paginate($builder, 20, 1);
|
|
$ids = $this->engine->mapIds($results);
|
|
|
|
$this->assertCount(2, $ids);
|
|
}
|
|
|
|
/**
|
|
* keysOnly 프루닝이 조립 시점에 이미 적용되어 있는지 (SELECT 에 키 + 스코어만).
|
|
*/
|
|
public function test_keys_narrows_select_to_key_and_score(): void
|
|
{
|
|
$this->seedRow('검색대상 문서', '본문 내용');
|
|
|
|
$captured = [];
|
|
DB::listen(function ($query) use (&$captured): void {
|
|
$captured[] = $query->sql;
|
|
});
|
|
|
|
$this->engine->keys($this->builder('검색대상'));
|
|
|
|
$selects = array_values(array_filter(
|
|
$captured,
|
|
static fn (string $sql): bool => str_contains($sql, self::TABLE) && str_starts_with(strtolower(ltrim($sql)), 'select'),
|
|
));
|
|
|
|
$this->assertNotEmpty($selects, 'keys() 가 SELECT 를 실행하지 않았습니다.');
|
|
|
|
$sql = $selects[0];
|
|
$this->assertStringContainsString('_ft_score', $sql, 'ORDER BY 가 참조하므로 스코어 별칭은 항상 SELECT 에 있어야 합니다.');
|
|
$this->assertStringNotContainsString(
|
|
'`'.DB::getTablePrefix().self::TABLE.'`.*',
|
|
$sql,
|
|
'keysOnly 경로는 전 컬럼을 읽지 않아야 합니다.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `keys()` 가 관련도 순서를 유지하는지.
|
|
*
|
|
* `reorder()` 로 정렬을 제거하는 안을 기각한 이유를 고정한다 — `keys()` 는 Scout 공개
|
|
* 계약상 관련도 순 키 목록을 약속하므로, 정렬을 지우면 오류 없이 무순서 키를 돌려주는
|
|
* 무음 회귀가 된다.
|
|
*/
|
|
public function test_keys_preserves_relevance_order(): void
|
|
{
|
|
// name 가중치가 description 보다 높다 → 이름에 매칭된 행이 앞선다.
|
|
$descriptionHit = $this->seedRow('무관한 제목', '본문에 검색대상 이 들어있다');
|
|
$nameHit = $this->seedRow('검색대상 문서', '무관한 본문');
|
|
|
|
$keys = $this->engine->keys($this->builder('검색대상'))->all();
|
|
|
|
$this->assertSame(
|
|
[$nameHit, $descriptionHit],
|
|
array_map('intval', $keys),
|
|
'name 가중치가 더 높으므로 이름 매칭이 먼저 와야 합니다.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FULLTEXT 엔진 테스트 전용 픽스처 모델.
|
|
*/
|
|
class FulltextEngineFixture extends Model implements FulltextSearchable
|
|
{
|
|
use Searchable;
|
|
|
|
protected $table = 'ft_engine_fixtures';
|
|
|
|
public $timestamps = false;
|
|
|
|
protected $guarded = [];
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function searchableColumns(): array
|
|
{
|
|
return ['name', 'description'];
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function searchableWeights(): array
|
|
{
|
|
return ['name' => 3.0, 'description' => 1.0];
|
|
}
|
|
}
|