Skip to content

Commit 7706d23

Browse files
Merge pull request #23 from ginkelsoft-development/test/expand-test-coverage
expand test coverage with comprehensive unit and edge case tests
2 parents 9998dba + 3cecfe5 commit 7706d23

File tree

6 files changed

+1082
-1
lines changed

6 files changed

+1082
-1
lines changed

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ protected function hasEncryptedCast(string $field): bool
319319
}
320320

321321
return str_contains(strtolower($casts[$field]), 'encrypted');
322-
}
322+
}
323+
324+
/**
323325
* Search for model IDs in Elasticsearch based on token(s).
324326
*
325327
* @param string $field
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Tests\Feature;
4+
5+
use Illuminate\Foundation\Testing\RefreshDatabase;
6+
use Illuminate\Support\Facades\Schema;
7+
use Illuminate\Database\Schema\Blueprint;
8+
use Orchestra\Testbench\TestCase;
9+
use Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider;
10+
use Ginkelsoft\EncryptedSearch\Models\SearchIndex;
11+
use Ginkelsoft\EncryptedSearch\Tests\Models\Client;
12+
13+
/**
14+
* Class HasEncryptedSearchIndexEdgeCasesTest
15+
*
16+
* Edge case and error handling tests for the HasEncryptedSearchIndex trait.
17+
*
18+
* Tests scenarios including:
19+
* - Empty field values
20+
* - Null values
21+
* - Special characters
22+
* - Non-encrypted fields
23+
* - Empty search queries
24+
* - SoftDeletes integration
25+
*
26+
* @package Ginkelsoft\EncryptedSearch\Tests\Feature
27+
* @covers \Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex
28+
*/
29+
class HasEncryptedSearchIndexEdgeCasesTest extends TestCase
30+
{
31+
use RefreshDatabase;
32+
33+
protected function getPackageProviders($app): array
34+
{
35+
return [EncryptedSearchServiceProvider::class];
36+
}
37+
38+
protected function setUp(): void
39+
{
40+
parent::setUp();
41+
42+
config()->set('database.default', 'testing');
43+
config()->set('database.connections.testing', [
44+
'driver' => 'sqlite',
45+
'database' => ':memory:',
46+
'prefix' => '',
47+
]);
48+
49+
config()->set('encrypted-search.elasticsearch.enabled', false);
50+
config()->set('encrypted-search.search_pepper', 'test-pepper-secret');
51+
52+
\Illuminate\Database\Eloquent\Model::unsetEventDispatcher();
53+
\Illuminate\Database\Eloquent\Model::setEventDispatcher(app('events'));
54+
\Ginkelsoft\EncryptedSearch\Tests\Models\Client::boot();
55+
56+
Schema::create('clients', function (Blueprint $table): void {
57+
$table->id();
58+
$table->string('first_names')->nullable();
59+
$table->string('last_names')->nullable();
60+
$table->timestamps();
61+
});
62+
63+
Schema::create('encrypted_search_index', function (Blueprint $table): void {
64+
$table->id();
65+
$table->string('model_type');
66+
$table->unsignedBigInteger('model_id');
67+
$table->string('field');
68+
$table->string('type');
69+
$table->string('token');
70+
$table->timestamps();
71+
$table->index(['model_type', 'field', 'type', 'token'], 'esi_lookup');
72+
});
73+
}
74+
75+
/**
76+
* Test that empty string fields do not generate tokens.
77+
*
78+
* @return void
79+
*/
80+
public function test_empty_string_fields_do_not_generate_tokens(): void
81+
{
82+
$client = Client::create([
83+
'first_names' => '',
84+
'last_names' => 'Doe',
85+
]);
86+
87+
$tokens = SearchIndex::where('model_id', $client->id)
88+
->where('field', 'first_names')
89+
->count();
90+
91+
$this->assertEquals(0, $tokens, 'Empty string should not generate tokens');
92+
}
93+
94+
/**
95+
* Test that null fields do not generate tokens.
96+
*
97+
* @return void
98+
*/
99+
public function test_null_fields_do_not_generate_tokens(): void
100+
{
101+
$client = Client::create([
102+
'first_names' => null,
103+
'last_names' => 'Doe',
104+
]);
105+
106+
$tokens = SearchIndex::where('model_id', $client->id)
107+
->where('field', 'first_names')
108+
->count();
109+
110+
$this->assertEquals(0, $tokens, 'Null values should not generate tokens');
111+
}
112+
113+
/**
114+
* Test that searching for empty string returns no results.
115+
*
116+
* @return void
117+
*/
118+
public function test_searching_for_empty_string_returns_no_results(): void
119+
{
120+
Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
121+
122+
$results = Client::encryptedExact('first_names', '')->get();
123+
124+
$this->assertCount(0, $results);
125+
}
126+
127+
/**
128+
* Test that fields with only special characters do not generate tokens.
129+
*
130+
* @return void
131+
*/
132+
public function test_special_characters_only_do_not_generate_tokens(): void
133+
{
134+
$client = Client::create([
135+
'first_names' => '!!!@@@',
136+
'last_names' => 'Doe',
137+
]);
138+
139+
$tokens = SearchIndex::where('model_id', $client->id)
140+
->where('field', 'first_names')
141+
->count();
142+
143+
$this->assertEquals(0, $tokens, 'Special characters only should not generate tokens');
144+
}
145+
146+
/**
147+
* Test that spaces are removed during normalization.
148+
*
149+
* @return void
150+
*/
151+
public function test_spaces_are_normalized_correctly(): void
152+
{
153+
Client::create(['first_names' => 'John Paul', 'last_names' => 'Doe']);
154+
155+
// Search without space should match
156+
$results = Client::encryptedExact('first_names', 'JohnPaul')->get();
157+
$this->assertCount(1, $results);
158+
159+
// Search with space should also match (gets normalized)
160+
$results = Client::encryptedExact('first_names', 'John Paul')->get();
161+
$this->assertCount(1, $results);
162+
}
163+
164+
/**
165+
* Test that diacritics are handled consistently.
166+
*
167+
* @return void
168+
*/
169+
public function test_diacritics_are_handled_consistently(): void
170+
{
171+
Client::create(['first_names' => 'José', 'last_names' => 'Garcia']);
172+
173+
// Search without diacritic should match
174+
$results = Client::encryptedExact('first_names', 'Jose')->get();
175+
$this->assertCount(1, $results);
176+
177+
// Search with diacritic should also match
178+
$results = Client::encryptedExact('first_names', 'José')->get();
179+
$this->assertCount(1, $results);
180+
}
181+
182+
/**
183+
* Test that case is handled consistently.
184+
*
185+
* @return void
186+
*/
187+
public function test_case_is_handled_consistently(): void
188+
{
189+
Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
190+
191+
// Lowercase search
192+
$results = Client::encryptedExact('first_names', 'john')->get();
193+
$this->assertCount(1, $results);
194+
195+
// Uppercase search
196+
$results = Client::encryptedExact('first_names', 'JOHN')->get();
197+
$this->assertCount(1, $results);
198+
199+
// Mixed case search
200+
$results = Client::encryptedExact('first_names', 'JoHn')->get();
201+
$this->assertCount(1, $results);
202+
}
203+
204+
/**
205+
* Test that updating with empty value removes previous tokens.
206+
*
207+
* @return void
208+
*/
209+
public function test_updating_with_empty_value_removes_tokens(): void
210+
{
211+
$client = Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
212+
213+
$initialCount = SearchIndex::where('model_id', $client->id)
214+
->where('field', 'first_names')
215+
->count();
216+
217+
$this->assertGreaterThan(0, $initialCount);
218+
219+
$client->update(['first_names' => '']);
220+
221+
$finalCount = SearchIndex::where('model_id', $client->id)
222+
->where('field', 'first_names')
223+
->count();
224+
225+
$this->assertEquals(0, $finalCount);
226+
}
227+
228+
/**
229+
* Test that prefix search with single character works.
230+
*
231+
* @return void
232+
*/
233+
public function test_prefix_search_with_single_character(): void
234+
{
235+
Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
236+
Client::create(['first_names' => 'Jane', 'last_names' => 'Smith']);
237+
Client::create(['first_names' => 'Bob', 'last_names' => 'Johnson']);
238+
239+
$results = Client::encryptedPrefix('first_names', 'J')->get();
240+
241+
$this->assertCount(2, $results, 'Single character prefix should match John and Jane');
242+
}
243+
244+
/**
245+
* Test that non-existent search terms return no results.
246+
*
247+
* @return void
248+
*/
249+
public function test_non_existent_search_terms_return_no_results(): void
250+
{
251+
Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
252+
253+
$results = Client::encryptedExact('first_names', 'NonExistent')->get();
254+
$this->assertCount(0, $results);
255+
256+
$results = Client::encryptedPrefix('first_names', 'XYZ')->get();
257+
$this->assertCount(0, $results);
258+
}
259+
260+
/**
261+
* Test that multiple models can have the same encrypted value.
262+
*
263+
* @return void
264+
*/
265+
public function test_multiple_models_with_same_value(): void
266+
{
267+
Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
268+
Client::create(['first_names' => 'John', 'last_names' => 'Smith']);
269+
270+
$results = Client::encryptedExact('first_names', 'John')->get();
271+
$this->assertCount(2, $results);
272+
}
273+
274+
/**
275+
* Test that very long strings are handled correctly.
276+
*
277+
* @return void
278+
*/
279+
public function test_very_long_strings_are_handled(): void
280+
{
281+
$longString = str_repeat('a', 1000);
282+
$client = Client::create(['first_names' => $longString, 'last_names' => 'Doe']);
283+
284+
$tokens = SearchIndex::where('model_id', $client->id)
285+
->where('field', 'first_names')
286+
->count();
287+
288+
$this->assertGreaterThan(0, $tokens);
289+
290+
$results = Client::encryptedExact('first_names', $longString)->get();
291+
$this->assertCount(1, $results);
292+
}
293+
294+
/**
295+
* Test that numbers are preserved in normalization.
296+
*
297+
* @return void
298+
*/
299+
public function test_numbers_are_preserved(): void
300+
{
301+
Client::create(['first_names' => 'User123', 'last_names' => 'Doe']);
302+
303+
$results = Client::encryptedExact('first_names', 'User123')->get();
304+
$this->assertCount(1, $results);
305+
306+
$results = Client::encryptedExact('first_names', 'user123')->get();
307+
$this->assertCount(1, $results);
308+
}
309+
310+
/**
311+
* Test that prefix search respects max depth configuration.
312+
*
313+
* @return void
314+
*/
315+
public function test_prefix_search_respects_max_depth(): void
316+
{
317+
config()->set('encrypted-search.max_prefix_depth', 3);
318+
319+
$client = Client::create(['first_names' => 'Alexander', 'last_names' => 'Doe']);
320+
321+
// Count prefix tokens (should be max 3)
322+
$prefixTokens = SearchIndex::where('model_id', $client->id)
323+
->where('field', 'first_names')
324+
->where('type', 'prefix')
325+
->count();
326+
327+
$this->assertEquals(3, $prefixTokens, 'Should only generate 3 prefix tokens');
328+
}
329+
330+
/**
331+
* Test that updating model without changing indexed fields does not cause errors.
332+
*
333+
* @return void
334+
*/
335+
public function test_updating_non_indexed_fields_works(): void
336+
{
337+
$client = Client::create(['first_names' => 'John', 'last_names' => 'Doe']);
338+
339+
$initialCount = SearchIndex::where('model_id', $client->id)->count();
340+
341+
// Update timestamps (which are not indexed)
342+
$client->touch();
343+
344+
$finalCount = SearchIndex::where('model_id', $client->id)->count();
345+
346+
$this->assertEquals($initialCount, $finalCount);
347+
}
348+
}

0 commit comments

Comments
 (0)