Files
Gnuboard7/tests/Unit/Search/DatabaseFulltextEngineTest.php
T
HeuJung 7f457a05b3 fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비
- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고,
 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더
- 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록)
- 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝,
 결제 3플러그인 컨트롤러 51지점 모델 파생 전환)
- audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase
 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
2026-08-17 03:02:26 +09:00

503 lines
17 KiB
PHP

<?php
namespace Tests\Unit\Search;
use App\Search\Contracts\FulltextSearchable;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use Laravel\Scout\Builder;
use Laravel\Scout\Searchable;
use Mockery;
use Tests\TestCase;
/**
* DatabaseFulltextEngine 단위 테스트
*
* MySQL FULLTEXT + ngram 커스텀 Scout 엔진의 동작을 검증합니다.
*/
class DatabaseFulltextEngineTest extends TestCase
{
private DatabaseFulltextEngine $engine;
protected function setUp(): void
{
parent::setUp();
$this->engine = new DatabaseFulltextEngine;
// 픽스처 테이블(test_table)은 실존하지 않으므로 인덱스 카탈로그를 시드해
// MATCH 조립 경로를 검증한다 — 시드하지 않으면 인덱스 부재 게이트(#103)가
// LIKE 로 내려 보내 조립 단언이 무의미해진다.
DatabaseFulltextEngine::primeFulltextIndexCatalog([
'test_table' => [['name']],
]);
}
protected function tearDown(): void
{
DatabaseFulltextEngine::forgetFulltextIndexCatalog();
parent::tearDown();
}
/**
* update()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_update_is_noop(): void
{
$models = new Collection;
// 예외 없이 정상 실행되어야 함
$this->engine->update($models);
$this->assertTrue(true);
}
/**
* delete()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_delete_is_noop(): void
{
$models = new Collection;
$this->engine->delete($models);
$this->assertTrue(true);
}
/**
* flush()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_flush_is_noop(): void
{
$model = Mockery::mock(Model::class);
$this->engine->flush($model);
$this->assertTrue(true);
}
/**
* createIndex()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_create_index_is_noop(): void
{
$this->engine->createIndex('test_index');
$this->assertTrue(true);
}
/**
* deleteIndex()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_delete_index_is_noop(): void
{
$this->engine->deleteIndex('test_index');
$this->assertTrue(true);
}
/**
* 빈 검색어로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_blank_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, ' ');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* 빈 검색어로 검색 시 getTotalCount가 0을 반환하는지 테스트합니다.
*/
public function test_get_total_count_returns_zero_for_empty_results(): void
{
$results = ['total' => 0];
$this->assertEquals(0, $this->engine->getTotalCount($results));
}
/**
* getTotalCount가 올바른 값을 반환하는지 테스트합니다.
*/
public function test_get_total_count_returns_correct_value(): void
{
$results = ['total' => 42];
$this->assertEquals(42, $this->engine->getTotalCount($results));
}
/**
* mapIds가 빈 결과에 대해 빈 컬렉션을 반환하는지 테스트합니다.
*/
public function test_map_ids_returns_empty_collection_for_null_query(): void
{
$results = ['query' => null];
$ids = $this->engine->mapIds($results);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $ids);
$this->assertTrue($ids->isEmpty());
}
/**
* map()이 빈 결과에 대해 빈 컬렉션을 반환하는지 테스트합니다.
*/
public function test_map_returns_empty_collection_for_null_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, 'test');
$results = ['query' => null];
$mapped = $this->engine->map($builder, $results, $model);
$this->assertInstanceOf(Collection::class, $mapped);
$this->assertTrue($mapped->isEmpty());
}
/**
* lazyMap()이 빈 결과에 대해 빈 LazyCollection을 반환하는지 테스트합니다.
*/
public function test_lazy_map_returns_empty_for_null_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, 'test');
$results = ['query' => null];
$mapped = $this->engine->lazyMap($builder, $results, $model);
$this->assertInstanceOf(LazyCollection::class, $mapped);
$this->assertTrue($mapped->isEmpty());
}
/**
* FulltextSearchable 미구현 모델로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_non_fulltext_searchable_model(): void
{
$model = new NonFulltextModel;
$builder = new Builder($model, '검색어');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* searchableColumns()가 빈 배열인 모델로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_empty_searchable_columns(): void
{
$model = $this->createSearchableModelWithEmptyColumns();
$builder = new Builder($model, '검색어');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* supportsFulltext()가 MySQL/MariaDB에서 true를 반환하는지 테스트합니다.
*/
public function test_supports_fulltext_returns_true_for_mysql(): void
{
// 현재 테스트 환경의 드라이버를 확인
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->assertTrue(DatabaseFulltextEngine::supportsFulltext());
} else {
$this->assertFalse(DatabaseFulltextEngine::supportsFulltext());
}
}
/**
* supportsNgramParser()가 MySQL에서만 true를 반환하는지 테스트합니다.
* MariaDB를 mysql 드라이버로 연결한 경우에도 false를 반환해야 합니다.
*/
public function test_supports_ngram_parser_returns_true_only_for_mysql(): void
{
$driver = DB::getDriverName();
if ($driver === 'mariadb') {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
} elseif ($driver === 'mysql') {
// mysql 드라이버라도 실제 MariaDB이면 false
if (DatabaseFulltextEngine::isMariaDb()) {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
} else {
$this->assertTrue(DatabaseFulltextEngine::supportsNgramParser());
}
} else {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
}
}
/**
* isMariaDb()가 mariadb 드라이버에서 true를 반환하는지 테스트합니다.
*/
public function test_is_mariadb_detects_mariadb_driver(): void
{
$driver = DB::getDriverName();
if ($driver === 'mariadb') {
$this->assertTrue(DatabaseFulltextEngine::isMariaDb());
} elseif ($driver === 'mysql') {
// mysql 드라이버로 MariaDB에 연결한 경우 서버 버전으로 감지
$version = DB::selectOne('SELECT VERSION() as version');
$isActuallyMariaDb = $version && str_contains(strtolower($version->version), 'mariadb');
$this->assertEquals($isActuallyMariaDb, DatabaseFulltextEngine::isMariaDb());
} else {
$this->assertFalse(DatabaseFulltextEngine::isMariaDb());
}
}
/**
* whereFulltext()가 FULLTEXT 지원 DBMS에서 MATCH...AGAINST를 생성하는지 테스트합니다.
*/
public function test_where_fulltext_generates_match_against_on_mysql(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS에서는 LIKE fallback 테스트로 대체');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
}
/**
* whereFulltext()가 OR 조건을 올바르게 생성하는지 테스트합니다.
*/
public function test_where_fulltext_generates_or_condition(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery()->where('id', 1);
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어', 'or');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
}
/**
* whereFulltext()가 FULLTEXT 미지원 DBMS에서 LIKE fallback을 생성하는지 테스트합니다.
*/
public function test_where_fulltext_like_fallback_on_non_fulltext_dbms(): void
{
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 지원 DBMS에서는 MATCH 테스트로 대체');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어');
$sql = $query->toSql();
$this->assertStringContainsString('LIKE', $sql);
}
/**
* addFulltextIndex()가 FULLTEXT 미지원 DBMS에서 예외 없이 스킵하는지 테스트합니다.
*/
public function test_add_fulltext_index_skips_on_non_fulltext_dbms(): void
{
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 지원 DBMS에서는 스킵 테스트 불가');
}
// 예외 없이 정상 스킵되어야 함
DatabaseFulltextEngine::addFulltextIndex('non_existent_table', 'ft_test', 'name');
$this->assertTrue(true);
}
/**
* sanitizeBooleanModeKeyword()가 BOOLEAN MODE 연산자를 제거하고 phrase로 변환하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_wraps_tokens_in_phrases(): void
{
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('셔츠'));
$this->assertSame('"shirt"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('shirt'));
// 다중 토큰 → 각각 phrase
$this->assertSame('"셔츠" "면"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('셔츠 면'));
}
/**
* sanitizeBooleanModeKeyword()가 각 BOOLEAN MODE 연산자를 제거하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_strips_operators(): void
{
// 연산자가 제거되고 남은 토큰만 phrase 로 묶임
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+상품'));
$this->assertSame('"바지"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('-바지'));
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('상품)'));
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('(셔츠'));
$this->assertSame('"키워드"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('~키워드'));
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('상품*'));
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('"셔츠'));
$this->assertSame('"user"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('@user'));
// <script> → 연산자/태그 제거 후 'script'
$this->assertSame('"script"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('<script>'));
}
/**
* sanitizeBooleanModeKeyword()가 연산자만 입력 시 빈 문자열을 반환하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_returns_empty_for_operators_only(): void
{
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('< >'));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+-~*'));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword(' '));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword(''));
}
/**
* sanitizeBooleanModeKeyword()가 제어문자를 제거하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_removes_control_chars(): void
{
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword("셔츠\x00"));
$this->assertSame('"a" "b"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword("a\tb"));
}
/**
* whereFulltext()가 특수문자 입력 시 정제된 안전한 phrase를 바인딩하는지 테스트합니다.
*/
public function test_where_fulltext_binds_sanitized_keyword(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '<script>');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
// 원본 특수문자가 아닌 정제된 phrase 가 바인딩
$this->assertContains('"script"', $query->getBindings());
}
/**
* whereFulltext()가 연산자만 입력 시 항상 거짓인 조건으로 빈 결과를 만드는지 테스트합니다.
*
* 조건이 어떤 문자열로 렌더되는지(`1 = 0` / `0 = 1`)는 빌더 구현 사항이므로 단언하지
* 않는다. 여기서 고정할 것은 **매칭을 시도하지 않고(AGAINST 없음) 결과가 비어야 한다**
* 는 동작이다. 리터럴을 박아 두면 빈 whereIn 처럼 동등한 구현으로 바꿀 때 깨진다.
*/
public function test_where_fulltext_uses_false_condition_for_operators_only(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '< >');
$sql = $query->toSql();
// 매칭을 시도하지 않는다
$this->assertStringNotContainsString('AGAINST', $sql);
// 조건이 아예 없으면 전체 행이 나온다 — 조건은 반드시 붙어야 한다
$this->assertStringContainsString('where', strtolower($sql));
// 그 조건은 어떤 행도 통과시키지 않는 상수 거짓이어야 한다.
// `1 = 0`(raw)과 `0 = 1`(빈 whereIn) 은 같은 뜻이므로 둘 다 허용한다.
$this->assertMatchesRegularExpression('/\b(?:1\s*=\s*0|0\s*=\s*1)\b/', $sql);
// 상수 거짓 조건에는 바인딩이 없다 (사용자 입력이 새어 들어가지 않았다는 뜻)
$this->assertSame([], $query->getBindings());
}
/**
* Searchable + FulltextSearchable 모델을 생성합니다.
*
* @return Model&FulltextSearchable
*/
private function createSearchableModel(): Model
{
return new class extends Model implements FulltextSearchable
{
use Searchable;
protected $table = 'test_table';
public function searchableColumns(): array
{
return ['name', 'description'];
}
public function searchableWeights(): array
{
return ['name' => 2.0, 'description' => 1.0];
}
};
}
/**
* 빈 컬럼을 가진 FulltextSearchable 모델을 생성합니다.
*
* @return Model&FulltextSearchable
*/
private function createSearchableModelWithEmptyColumns(): Model
{
return new class extends Model implements FulltextSearchable
{
use Searchable;
protected $table = 'test_table';
public function searchableColumns(): array
{
return [];
}
public function searchableWeights(): array
{
return [];
}
};
}
}
/**
* FulltextSearchable을 구현하지 않은 테스트용 모델
*/
class NonFulltextModel extends Model
{
use Searchable;
protected $table = 'test_non_fulltext';
public function toSearchableArray(): array
{
return ['title' => 'test'];
}
}