1271 lines
51 KiB
PHP
1271 lines
51 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Search;
|
|
|
|
use App\Models\Asset;
|
|
use App\Models\AssetModel;
|
|
use App\Models\Category;
|
|
use App\Models\CustomField;
|
|
use App\Models\License;
|
|
use App\Models\Location;
|
|
use App\Models\Manufacturer;
|
|
use App\Models\Statuslabel;
|
|
use App\Models\Supplier;
|
|
use App\Models\User;
|
|
use Illuminate\Testing\Fluent\AssertableJson;
|
|
use PHPUnit\Framework\Attributes\Group;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Test the Searchable trait across multiple search modes:
|
|
* - Free-text search (search=term)
|
|
* - Structured filter search (filter={"field":"value"})
|
|
*
|
|
* Tests verify that:
|
|
* 1. Attributes are searchable via both modes
|
|
* 2. Relations are searchable via both modes
|
|
* 3. Relation aliases (e.g., status_label → status) work correctly
|
|
* 4. Multi-word searches work as expected
|
|
*/
|
|
class SearchableTraitTest extends TestCase
|
|
{
|
|
/**
|
|
* Test Asset free-text search on attributes
|
|
*/
|
|
public function test_asset_free_text_search_on_attributes()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro 15"', 'asset_tag' => 'ASSET-001']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'ASSET-002']);
|
|
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'ASSET-003']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'MacBook']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'ASSET']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 3)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Asset free-text search on relations
|
|
*/
|
|
public function test_asset_free_text_search_on_relations()
|
|
{
|
|
// Create fresh test data that won't conflict with system data
|
|
$supplier = Supplier::factory()->create(['name' => 'TestVendor-'.now()->timestamp]);
|
|
$location = Location::factory()->create(['name' => 'TestBuilding-'.now()->timestamp]);
|
|
|
|
Asset::factory()->create([
|
|
'name' => 'Asset 1',
|
|
'supplier_id' => $supplier->id,
|
|
'location_id' => $location->id,
|
|
]);
|
|
|
|
Asset::factory()->create(['name' => 'Asset 2']);
|
|
|
|
// Search by supplier name
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'TestVendor']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Search by location name
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'TestBuilding']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Asset structured filter search on attributes
|
|
*/
|
|
public function test_asset_structured_filter_on_attributes()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro 15"', 'serial' => 'SN123456']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'serial' => 'SN789012']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => 'MacBook']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['serial' => 'SN789']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Asset structured filter search on relations
|
|
*/
|
|
public function test_asset_structured_filter_on_relations()
|
|
{
|
|
$supplier = Supplier::factory()->create(['name' => 'TechVendor Inc']);
|
|
$location = Location::factory()->create(['name' => 'Building A']);
|
|
$manufacturer = Manufacturer::factory()->apple()->create();
|
|
$model = AssetModel::factory()->create(['manufacturer_id' => $manufacturer->id]);
|
|
$category = Category::factory()->assetLaptopCategory()->create();
|
|
|
|
Asset::factory()->create([
|
|
'name' => 'Asset 1',
|
|
'model_id' => $model->id,
|
|
'supplier_id' => $supplier->id,
|
|
'location_id' => $location->id,
|
|
]);
|
|
|
|
Asset::factory()->create(['name' => 'Asset 2']);
|
|
|
|
// Filter by supplier name
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['supplier' => 'TechVendor']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Filter by location name
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['location' => 'Building']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Filter by manufacturer name (nested relation via model)
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['manufacturer' => 'Apple']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Asset structured filter using relation alias (status_label → status)
|
|
*/
|
|
public function test_asset_structured_filter_using_relation_alias()
|
|
{
|
|
// Create a unique status to avoid conflicts with system data
|
|
$status = Statuslabel::factory()->create(['name' => 'TestStatus-'.now()->timestamp]);
|
|
|
|
Asset::factory()->create(['status_id' => $status->id]);
|
|
Asset::factory()->create();
|
|
|
|
// Filter using the API key 'status_label' should map to 'status' relation
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['status_label' => 'TestStatus']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test License free-text search on attributes
|
|
*/
|
|
public function test_license_free_text_search_on_attributes()
|
|
{
|
|
License::factory()->create(['name' => 'Microsoft Office 365', 'serial' => 'OFFICE-123']);
|
|
License::factory()->create(['name' => 'Adobe Creative Cloud', 'serial' => 'ADOBE-456']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', ['search' => 'Microsoft']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', ['search' => 'OFFICE']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test License free-text search on relations
|
|
*/
|
|
public function test_license_free_text_search_on_relations()
|
|
{
|
|
$manufacturer = Manufacturer::factory()->microsoft()->create();
|
|
$supplier = Supplier::factory()->create(['name' => 'CloudVendor Inc']);
|
|
|
|
License::factory()->create([
|
|
'name' => 'License 1',
|
|
'manufacturer_id' => $manufacturer->id,
|
|
'supplier_id' => $supplier->id,
|
|
]);
|
|
|
|
License::factory()->create(['name' => 'License 2']);
|
|
|
|
// Search by manufacturer name
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', ['search' => 'Microsoft']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Search by supplier name
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', ['search' => 'CloudVendor']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test License structured filter search on attributes
|
|
*/
|
|
public function test_license_structured_filter_on_attributes()
|
|
{
|
|
License::factory()->create(['name' => 'Microsoft Office', 'serial' => 'SN-OFFICE-001']);
|
|
License::factory()->create(['name' => 'Adobe Suite', 'serial' => 'SN-ADOBE-002']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', [
|
|
'filter' => json_encode(['name' => 'Office']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', [
|
|
'filter' => json_encode(['serial' => 'ADOBE']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test License structured filter search on relations
|
|
*/
|
|
public function test_license_structured_filter_on_relations()
|
|
{
|
|
$manufacturer = Manufacturer::factory()->adobe()->create();
|
|
$supplier = Supplier::factory()->create(['name' => 'TechSupply Inc']);
|
|
|
|
License::factory()->create([
|
|
'name' => 'License 1',
|
|
'manufacturer_id' => $manufacturer->id,
|
|
'supplier_id' => $supplier->id,
|
|
]);
|
|
|
|
License::factory()->create(['name' => 'License 2']);
|
|
|
|
// Filter by manufacturer
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', [
|
|
'filter' => json_encode(['manufacturer' => 'Adobe']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Filter by supplier
|
|
$this->actingAsForApi(User::factory()->viewLicenses()->create())
|
|
->getJson(route('api.licenses.index', [
|
|
'filter' => json_encode(['supplier' => 'TechSupply']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test User free-text search on attributes
|
|
*/
|
|
#[Group('skip-flaky')]
|
|
public function test_user_free_text_search_on_attributes()
|
|
{
|
|
// Note: User search includes the acting user in results, making this test flaky
|
|
// Use the username search instead which is more deterministic
|
|
$timestamp = now()->timestamp;
|
|
$uniqueName = 'XYZ'.$timestamp;
|
|
User::factory()->create(['first_name' => 'TestJohn'.$uniqueName, 'last_name' => 'Smith'.$uniqueName, 'username' => 'jsmith'.$uniqueName]);
|
|
User::factory()->create(['first_name' => 'TestJane'.$uniqueName, 'last_name' => 'Doe'.$uniqueName, 'username' => 'jdoe'.$uniqueName]);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', ['search' => 'jsmith'.$uniqueName]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test User multi-word search (first_name + last_name concat)
|
|
*/
|
|
public function test_user_multi_word_free_text_search()
|
|
{
|
|
$timestamp = now()->timestamp;
|
|
$uniqueName = 'ABC'.$timestamp;
|
|
User::factory()->create(['first_name' => 'TestJohn'.$uniqueName, 'last_name' => 'Smith'.$uniqueName, 'username' => 'jsmith'.$uniqueName]);
|
|
User::factory()->create(['first_name' => 'TestJane'.$uniqueName, 'last_name' => 'Doe'.$uniqueName, 'username' => 'jdoe'.$uniqueName]);
|
|
|
|
// Search for full name should match when both first and last are concatenated
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', ['search' => 'TestJohn'.$uniqueName.' Smith']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test User structured filter on attributes
|
|
*/
|
|
public function test_user_structured_filter_on_attributes()
|
|
{
|
|
$timestamp = now()->timestamp;
|
|
$uniqueName = 'DEF'.$timestamp;
|
|
User::factory()->create(['first_name' => 'TestJohn'.$uniqueName, 'last_name' => 'Smith'.$uniqueName, 'email' => 'john'.$uniqueName.'@example.com']);
|
|
User::factory()->create(['first_name' => 'TestJane'.$uniqueName, 'last_name' => 'Doe'.$uniqueName, 'email' => 'jane'.$uniqueName.'@example.com']);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['first_name' => 'TestJohn'.$uniqueName]),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['email' => 'jane'.$uniqueName]),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* "name" is a virtual column on User (CONCAT of first_name + last_name).
|
|
* A positive filter should match on concatenated full name.
|
|
*/
|
|
public function test_user_name_virtual_column_filter_positive()
|
|
{
|
|
$ts = now()->timestamp;
|
|
User::factory()->create(['first_name' => 'VirtFirst'.$ts, 'last_name' => 'VirtLast'.$ts]);
|
|
User::factory()->create(['first_name' => 'Other'.$ts, 'last_name' => 'Person'.$ts]);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['name' => 'VirtFirst'.$ts]),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* A negated "name" filter using the "!" prefix should exclude matching users,
|
|
* returning only those whose full name does NOT contain the term.
|
|
*/
|
|
public function test_user_name_virtual_column_filter_negation_bang_prefix()
|
|
{
|
|
$ts = now()->timestamp;
|
|
$negUser = User::factory()->create(['first_name' => 'NegFirst'.$ts, 'last_name' => 'NegLast'.$ts]);
|
|
$safeUser = User::factory()->create(['first_name' => 'SafeFirst'.$ts, 'last_name' => 'SafeLast'.$ts]);
|
|
|
|
$response = $this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['name' => '!NegFirst'.$ts]),
|
|
]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
// The matched (negated) user must not appear in results.
|
|
$this->assertNotContains((int) $negUser->id, $returnedIds);
|
|
|
|
// The safe user should appear in results.
|
|
$this->assertContains((int) $safeUser->id, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* A negated "name" filter using the "not:" prefix should behave identically to "!".
|
|
*/
|
|
public function test_user_name_virtual_column_filter_negation_not_colon_prefix()
|
|
{
|
|
$ts = now()->timestamp;
|
|
User::factory()->create(['first_name' => 'NotFirst'.$ts, 'last_name' => 'NotLast'.$ts]);
|
|
|
|
$response = $this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['name' => 'not:NotFirst'.$ts]),
|
|
]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertNotContains(
|
|
(int) User::where('first_name', 'NotFirst'.$ts)->value('id'),
|
|
$returnedIds
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test Category free-text search on attributes
|
|
*/
|
|
public function test_category_free_text_search_on_attributes()
|
|
{
|
|
Category::factory()->assetLaptopCategory()->create();
|
|
Category::factory()->assetDesktopCategory()->create();
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.categories.index', ['search' => 'Laptop']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.categories.index', ['search' => 'Desktop']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Category structured filter on attributes
|
|
*/
|
|
public function test_category_structured_filter_on_attributes()
|
|
{
|
|
Category::factory()->assetLaptopCategory()->create(['notes' => 'For portable computing']);
|
|
Category::factory()->assetDesktopCategory()->create(['notes' => 'For stationary computing']);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.categories.index', [
|
|
'filter' => json_encode(['name' => 'Laptop']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.categories.index', [
|
|
'filter' => json_encode(['notes' => 'portable']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Manufacturer free-text search on attributes
|
|
*/
|
|
public function test_manufacturer_free_text_search_on_attributes()
|
|
{
|
|
Manufacturer::factory()->apple()->create();
|
|
Manufacturer::factory()->microsoft()->create();
|
|
Manufacturer::factory()->dell()->create();
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.manufacturers.index', ['search' => 'Apple']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.manufacturers.index', ['search' => 'Microsoft']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Manufacturer structured filter on attributes
|
|
*/
|
|
public function test_manufacturer_structured_filter_on_attributes()
|
|
{
|
|
Manufacturer::factory()->apple()->create();
|
|
Manufacturer::factory()->microsoft()->create();
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.manufacturers.index', [
|
|
'filter' => json_encode(['name' => 'Apple']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Location free-text search on attributes
|
|
*/
|
|
public function test_location_free_text_search_on_attributes()
|
|
{
|
|
Location::factory()->create(['name' => 'Building A', 'city' => 'New York']);
|
|
Location::factory()->create(['name' => 'Building B', 'city' => 'Los Angeles']);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.locations.index', ['search' => 'Building']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.locations.index', ['search' => 'New York']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test Location structured filter on attributes
|
|
*/
|
|
public function test_location_structured_filter_on_attributes()
|
|
{
|
|
Location::factory()->create(['name' => 'Building A', 'city' => 'New York']);
|
|
Location::factory()->create(['name' => 'Building B', 'city' => 'Los Angeles']);
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.locations.index', [
|
|
'filter' => json_encode(['city' => 'New York']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.locations.index', [
|
|
'filter' => json_encode(['name' => 'Building']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test partial word matching works in both search modes
|
|
*/
|
|
public function test_partial_word_matching()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro 15"']);
|
|
|
|
// Free-text search
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'Book']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Filter search
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => 'Pro']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test search is case-insensitive
|
|
*/
|
|
public function test_search_is_case_insensitive()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro 15"']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'macbook']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => 'MACBOOK']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test empty search/filter returns no special errors
|
|
*/
|
|
public function test_empty_search_returns_all_results()
|
|
{
|
|
Asset::factory()->count(3)->create();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => '']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 3)->etc());
|
|
}
|
|
|
|
/**
|
|
* Regression: passing search as an array (?search[]=foo) must not throw
|
|
* "Array to string conversion" — values should be joined and searched normally.
|
|
*/
|
|
public function test_array_search_param_does_not_throw()
|
|
{
|
|
Asset::factory()->create(['name' => 'ArraySearchMacBook']);
|
|
Asset::factory()->create(['name' => 'ArraySearchDell']);
|
|
|
|
// search[]=ArraySearchMacBook simulates ?search[]=ArraySearchMacBook in the URL
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => ['ArraySearchMacBook']]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
|
|
// Multiple array values must not throw — exact match count depends on join semantics
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => ['ArraySearchMacBook', 'ArraySearchDell']]))
|
|
->assertOk();
|
|
}
|
|
|
|
/**
|
|
* Test no results when search matches nothing
|
|
*/
|
|
public function test_search_no_results()
|
|
{
|
|
Asset::factory()->create(['name' => 'Asset 1']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'NonExistentTerm']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test asset free-text search across multiple custom fields.
|
|
*/
|
|
public function test_asset_free_text_search_matches_across_multiple_custom_fields()
|
|
{
|
|
$macFieldOne = CustomField::factory()->macAddress()->create([
|
|
'name' => 'MAC Address One '.now()->timestamp,
|
|
]);
|
|
$macFieldTwo = CustomField::factory()->macAddress()->create([
|
|
'name' => 'MAC Address Two '.(now()->timestamp + 1),
|
|
]);
|
|
|
|
$dbColumnOne = $macFieldOne->db_column_name();
|
|
$dbColumnTwo = $macFieldTwo->db_column_name();
|
|
|
|
$firstMatchingAsset = Asset::factory()->create([
|
|
$dbColumnOne => 'AA:BB:CC:11:22:33',
|
|
$dbColumnTwo => null,
|
|
]);
|
|
$secondMatchingAsset = Asset::factory()->create([
|
|
$dbColumnOne => null,
|
|
$dbColumnTwo => 'AA:BB:CC:44:55:66',
|
|
]);
|
|
Asset::factory()->create([
|
|
$dbColumnOne => '10:20:30:40:50:60',
|
|
$dbColumnTwo => '66:55:44:33:22:11',
|
|
]);
|
|
|
|
$response = $this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', ['search' => 'AA:BB:CC']))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
|
|
$returnedIds = collect($response->json('rows'))
|
|
->pluck('id')
|
|
->map(fn ($id) => (int) $id)
|
|
->sort()
|
|
->values()
|
|
->all();
|
|
|
|
$expectedIds = collect([$firstMatchingAsset->id, $secondMatchingAsset->id])
|
|
->sort()
|
|
->values()
|
|
->all();
|
|
|
|
$this->assertSame($expectedIds, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* Test filtering on a custom field using the raw db_column slug.
|
|
*/
|
|
public function test_custom_field_filter_by_db_column_slug()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz i9']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz i5']);
|
|
Asset::factory()->create([$dbColumn => null]);
|
|
|
|
// Flush cache so the newly created field is picked up.
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
// Filter using the raw db_column key.
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => '3.2GHz']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test filtering by a human-readable custom field name is ignored.
|
|
*/
|
|
public function test_custom_field_filter_by_human_readable_name_is_ignored()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz i9']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz i5']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['CPU' => 'i9']),
|
|
]))
|
|
->assertOk()
|
|
// Human-readable custom field keys are intentionally ignored.
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test custom field name collisions do not override relation filters.
|
|
*/
|
|
public function test_custom_field_name_collision_does_not_override_relation_filter()
|
|
{
|
|
$status = Statuslabel::factory()->create(['name' => 'CollisionStatus-'.now()->timestamp]);
|
|
$otherStatus = Statuslabel::factory()->create(['name' => 'DifferentStatus-'.now()->timestamp]);
|
|
|
|
$field = CustomField::factory()->create([
|
|
'name' => 'status',
|
|
'field_encrypted' => 0,
|
|
]);
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([
|
|
'status_id' => $status->id,
|
|
$dbColumn => 'custom-status-value',
|
|
]);
|
|
Asset::factory()->create([
|
|
'status_id' => $otherStatus->id,
|
|
$dbColumn => 'CollisionStatus',
|
|
]);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['status' => 'CollisionStatus']),
|
|
]))
|
|
->assertOk()
|
|
// This must filter the status relation, not the custom field with same name.
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test filtering on a custom field using the raw db_column slug.
|
|
*/
|
|
public function test_custom_field_gets_skipped_if_encrypted()
|
|
{
|
|
$field = CustomField::factory()->testEncrypted()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz i9']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz i5']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => 'i9']),
|
|
]))
|
|
->assertOk()
|
|
// Encrypted fields are not searchable, so this filter key is ignored.
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test that custom field filter returns no results when value doesn't match.
|
|
*/
|
|
public function test_custom_field_filter_returns_empty_when_no_match()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz i9']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => 'NonExistentCPU']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
|
|
}
|
|
|
|
/**
|
|
* "is:null" on a direct nullable attribute should match rows where that column is NULL.
|
|
* "is:not_null" should match rows where it is not NULL.
|
|
*/
|
|
public function test_is_null_filter_on_nullable_attribute()
|
|
{
|
|
$ts = now()->timestamp;
|
|
|
|
$withNotes = Asset::factory()->create(['notes' => 'Some notes '.$ts]);
|
|
$withoutNotes = Asset::factory()->create(['notes' => null]);
|
|
|
|
$superuser = User::factory()->viewAssets()->create();
|
|
|
|
// is:null → only the asset with no notes
|
|
$response = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $withoutNotes->id, $returnedIds);
|
|
$this->assertNotContains((int) $withNotes->id, $returnedIds);
|
|
|
|
// is:not_null → only the asset with notes
|
|
$response2 = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:not_null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds2 = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $withNotes->id, $returnedIds2);
|
|
$this->assertNotContains((int) $withoutNotes->id, $returnedIds2);
|
|
}
|
|
|
|
/**
|
|
* Blank string values should be treated like empty content for direct string fields.
|
|
*/
|
|
public function test_is_not_null_filter_excludes_blank_string_direct_attributes()
|
|
{
|
|
$populated = Asset::factory()->create([
|
|
'name' => 'Named Asset '.now()->timestamp,
|
|
'order_number' => 'PO-12345',
|
|
]);
|
|
$blank = Asset::factory()->create([
|
|
'name' => '',
|
|
'order_number' => '',
|
|
]);
|
|
|
|
$superuser = User::factory()->viewAssets()->create();
|
|
|
|
$response = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['order_number' => 'is:not_null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $populated->id, $returnedIds);
|
|
$this->assertNotContains((int) $blank->id, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* "is:not_null" on the User virtual "name" column should match users where
|
|
* at least one constituent column (first_name, last_name) is not null.
|
|
* All factory-created users have a first_name, so they should all appear.
|
|
*/
|
|
public function test_is_null_filter_on_virtual_name_column()
|
|
{
|
|
$ts = now()->timestamp;
|
|
|
|
$userWithName = User::factory()->create([
|
|
'first_name' => 'VirtNullFirst'.$ts,
|
|
'last_name' => 'VirtNullLast'.$ts,
|
|
]);
|
|
|
|
$superuser = User::factory()->superuser()->create();
|
|
|
|
// is:not_null → users with at least first_name set should be returned.
|
|
$response = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.users.index', ['filter' => json_encode(['name' => 'is:not_null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
// The user with an actual name must appear.
|
|
$this->assertContains((int) $userWithName->id, $returnedIds);
|
|
|
|
// The acting superuser itself also has a name, so it should appear too.
|
|
$this->assertContains((int) $superuser->id, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* "is:null" on a searchable relation key should return records that have no
|
|
* related record (equivalent to doesntHave).
|
|
* "is:not_null" should return only records that have a related record.
|
|
*/
|
|
public function test_is_null_filter_on_relation_key()
|
|
{
|
|
$ts = now()->timestamp;
|
|
|
|
$supplier = Supplier::factory()->create(['name' => 'RelNullSupplier'.$ts]);
|
|
$withSupplier = Asset::factory()->create(['supplier_id' => $supplier->id]);
|
|
$withoutSupplier = Asset::factory()->create(['supplier_id' => null]);
|
|
|
|
$superuser = User::factory()->viewAssets()->create();
|
|
|
|
// is:null on supplier → assets with no supplier
|
|
$response = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['supplier' => 'is:null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $withoutSupplier->id, $returnedIds);
|
|
$this->assertNotContains((int) $withSupplier->id, $returnedIds);
|
|
|
|
// is:not_null on supplier → assets with a supplier
|
|
$response2 = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['supplier' => 'is:not_null'])]))
|
|
->assertOk();
|
|
|
|
$returnedIds2 = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $withSupplier->id, $returnedIds2);
|
|
$this->assertNotContains((int) $withoutSupplier->id, $returnedIds2);
|
|
}
|
|
|
|
/**
|
|
* Regression: `assigned_to` is a polymorphic searchable relation key.
|
|
* `is:null` should return unassigned assets; `is:not_null` should return assigned assets.
|
|
*/
|
|
public function test_is_null_filter_on_polymorphic_assigned_to_relation_key()
|
|
{
|
|
/** @var User $assignee */
|
|
$assignee = User::factory()->create();
|
|
$assignedAsset = Asset::factory()->assignedToUser($assignee)->create();
|
|
$unassignedAsset = Asset::factory()->create([
|
|
'assigned_to' => null,
|
|
'assigned_type' => null,
|
|
]);
|
|
|
|
$superuser = User::factory()->viewAssets()->create();
|
|
|
|
$response = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['assigned_to' => 'is:null'])]))
|
|
->assertOk();
|
|
|
|
$returnedNullIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $unassignedAsset->id, $returnedNullIds);
|
|
$this->assertNotContains((int) $assignedAsset->id, $returnedNullIds);
|
|
|
|
$response2 = $this->actingAsForApi($superuser)
|
|
->getJson(route('api.assets.index', ['filter' => json_encode(['assigned_to' => 'is:not_null'])]))
|
|
->assertOk();
|
|
|
|
$returnedNotNullIds = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
$this->assertContains((int) $assignedAsset->id, $returnedNotNullIds);
|
|
$this->assertNotContains((int) $unassignedAsset->id, $returnedNotNullIds);
|
|
}
|
|
|
|
/**
|
|
* Test custom field partial match via filter.
|
|
*/
|
|
public function test_custom_field_filter_partial_match()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz Intel Core i9']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz AMD Ryzen 7']);
|
|
Asset::factory()->create([$dbColumn => null]);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => 'Intel']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Regression: custom field filters should support exact-match via "is:".
|
|
*/
|
|
public function test_custom_field_filter_exact_match_with_is_modifier()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz Intel Core i9']);
|
|
Asset::factory()->create([$dbColumn => '3.2GHz Intel Core i9 Pro']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz AMD Ryzen 7']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => 'is:3.2GHz Intel Core i9']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Regression: "is_not:" should perform an exact exclusion (not fuzzy).
|
|
*/
|
|
public function test_exact_exclusion_filter_with_is_not_prefix_on_attribute()
|
|
{
|
|
Asset::factory()->create(['name' => 'Dell', 'asset_tag' => 'ISNOT-ATTR-001']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'ISNOT-ATTR-002']);
|
|
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'ISNOT-ATTR-003']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => 'is_not:Dell']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Regression: "is_not:" on relations should exclude only exact relation values.
|
|
*/
|
|
public function test_exact_exclusion_filter_with_is_not_prefix_on_relation()
|
|
{
|
|
$apple = Manufacturer::factory()->create(['name' => 'Apple']);
|
|
$appleInc = Manufacturer::factory()->create(['name' => 'Apple Inc']);
|
|
$dell = Manufacturer::factory()->create(['name' => 'Dell']);
|
|
|
|
$appleModel = AssetModel::factory()->create(['manufacturer_id' => $apple->id]);
|
|
$appleIncModel = AssetModel::factory()->create(['manufacturer_id' => $appleInc->id]);
|
|
$dellModel = AssetModel::factory()->create(['manufacturer_id' => $dell->id]);
|
|
|
|
Asset::factory()->create(['model_id' => $appleModel->id, 'asset_tag' => 'ISNOT-REL-001']);
|
|
Asset::factory()->create(['model_id' => $appleIncModel->id, 'asset_tag' => 'ISNOT-REL-002']);
|
|
Asset::factory()->create(['model_id' => $dellModel->id, 'asset_tag' => 'ISNOT-REL-003']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['manufacturer' => 'is_not:Apple']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Regression: "is_not:" should perform exact exclusion on custom fields.
|
|
*/
|
|
public function test_exact_exclusion_filter_with_is_not_prefix_on_custom_field()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => 'Intel', 'asset_tag' => 'ISNOT-CF-001']);
|
|
Asset::factory()->create([$dbColumn => 'Intel Core i9', 'asset_tag' => 'ISNOT-CF-002']);
|
|
Asset::factory()->create([$dbColumn => 'AMD Ryzen 7', 'asset_tag' => 'ISNOT-CF-003']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => 'is_not:Intel']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test negation filter using "!" prefix on a direct attribute.
|
|
* filter={"name":"!Dell"} should return all assets whose name does NOT contain "Dell".
|
|
*/
|
|
public function test_negation_filter_with_bang_prefix_on_attribute()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'NEG-001']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'NEG-002']);
|
|
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'NEG-003']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => '!Dell']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test negation filter using "not:" prefix on a direct attribute.
|
|
* filter={"name":"not:Dell"} should behave identically to "!Dell".
|
|
*/
|
|
public function test_negation_filter_with_not_prefix_on_attribute()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'NOTP-001']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'NOTP-002']);
|
|
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'NOTP-003']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['name' => 'not:Dell']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test that combining a positive filter and a negation filter works correctly.
|
|
* filter={"asset_tag":"COMBO","name":"!Dell"} should return assets tagged COMBO that
|
|
* are NOT named Dell.
|
|
*/
|
|
public function test_combined_positive_and_negation_filters()
|
|
{
|
|
Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'COMBO-001']);
|
|
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'COMBO-002']);
|
|
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'OTHER-001']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['asset_tag' => 'COMBO', 'name' => '!Dell']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test negation filter on a relation attribute.
|
|
* filter={"manufacturer":"!Apple"} should return assets whose manufacturer does NOT
|
|
* contain "Apple".
|
|
*/
|
|
public function test_negation_filter_on_relation()
|
|
{
|
|
$apple = Manufacturer::factory()->create(['name' => 'Apple']);
|
|
$dell = Manufacturer::factory()->create(['name' => 'Dell']);
|
|
|
|
$appleModel = AssetModel::factory()->create(['manufacturer_id' => $apple->id]);
|
|
$dellModel = AssetModel::factory()->create(['manufacturer_id' => $dell->id]);
|
|
|
|
Asset::factory()->create(['model_id' => $appleModel->id, 'asset_tag' => 'REL-001']);
|
|
Asset::factory()->create(['model_id' => $dellModel->id, 'asset_tag' => 'REL-002']);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['manufacturer' => '!Apple']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Test negation filter on a custom field.
|
|
* filter={"_snipeit_cpu_X":"!Intel"} should return assets where the CPU field
|
|
* does NOT contain "Intel".
|
|
*/
|
|
public function test_negation_filter_on_custom_field()
|
|
{
|
|
$field = CustomField::factory()->cpu()->create();
|
|
$dbColumn = $field->db_column_name();
|
|
|
|
Asset::factory()->create([$dbColumn => '3.2GHz Intel Core i9', 'asset_tag' => 'CF-001']);
|
|
Asset::factory()->create([$dbColumn => '2.4GHz AMD Ryzen 7', 'asset_tag' => 'CF-002']);
|
|
|
|
Asset::flushCustomFieldFilterMap();
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([$dbColumn => '!Intel']),
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
|
|
/**
|
|
* Negation filter "!blah" on the "location" relation key for Assets
|
|
* should exclude assets with a location name containing "blah".
|
|
*/
|
|
public function test_negation_filter_on_asset_location_relation()
|
|
{
|
|
$ts = now()->timestamp;
|
|
|
|
$blahLocation = Location::factory()->create(['name' => 'Blah Office '.$ts]);
|
|
$safeLocation = Location::factory()->create(['name' => 'Safe Office '.$ts]);
|
|
|
|
$blahAsset = Asset::factory()->create(['location_id' => $blahLocation->id]);
|
|
$safeAsset = Asset::factory()->create(['location_id' => $safeLocation->id]);
|
|
|
|
$response = $this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode(['location' => '!Blah']),
|
|
]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
// Asset in the "blah" location must NOT appear.
|
|
$this->assertNotContains((int) $blahAsset->id, $returnedIds);
|
|
// Asset in a different location MUST appear.
|
|
$this->assertContains((int) $safeAsset->id, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* Negation filter "!blah" on the "location" relation key for Users
|
|
* should exclude users whose location name contains "blah".
|
|
*
|
|
* The User model stores location via the "userloc" Eloquent relation
|
|
* (not "location"), so a "location" → "userloc" alias must be registered.
|
|
*/
|
|
public function test_negation_filter_on_user_location_relation()
|
|
{
|
|
$ts = now()->timestamp;
|
|
|
|
$blahLocation = Location::factory()->create([
|
|
'name' => 'Blah Floor '.$ts,
|
|
'address' => 'Safe Address '.$ts,
|
|
]);
|
|
$safeLocation = Location::factory()->create([
|
|
'name' => 'Safe Floor '.$ts,
|
|
// Regression guard: structured filter on "location" should not inspect address.
|
|
'address' => 'Blah Address '.$ts,
|
|
]);
|
|
|
|
$blahUser = User::factory()->create(['location_id' => $blahLocation->id]);
|
|
$safeUser = User::factory()->create(['location_id' => $safeLocation->id]);
|
|
$nullLocationUser = User::factory()->create(['location_id' => null]);
|
|
|
|
$response = $this->actingAsForApi(User::factory()->superuser()->create())
|
|
->getJson(route('api.users.index', [
|
|
'filter' => json_encode(['location' => '!Blah']),
|
|
]))
|
|
->assertOk();
|
|
|
|
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
|
// The user in the "blah" location must NOT appear.
|
|
$this->assertNotContains((int) $blahUser->id, $returnedIds);
|
|
// The user in a safe location MUST appear.
|
|
$this->assertContains((int) $safeUser->id, $returnedIds);
|
|
// Users with no location should also be included for negated filters.
|
|
$this->assertContains((int) $nullLocationUser->id, $returnedIds);
|
|
}
|
|
|
|
/**
|
|
* Regression: structured AND filter should honor model_number and location together.
|
|
*/
|
|
public function test_asset_structured_filter_and_operator_with_model_number_and_location()
|
|
{
|
|
$locationA = Location::factory()->create(['name' => 'HQ-East']);
|
|
$locationB = Location::factory()->create(['name' => 'HQ-West']);
|
|
$manufacturer = Manufacturer::factory()->create(['name' => 'FilterCo']);
|
|
|
|
$modelMatch = AssetModel::factory()->create([
|
|
'manufacturer_id' => $manufacturer->id,
|
|
'model_number' => 'MODEL-111',
|
|
]);
|
|
|
|
$modelOther = AssetModel::factory()->create([
|
|
'manufacturer_id' => $manufacturer->id,
|
|
'model_number' => 'MODEL-222',
|
|
]);
|
|
|
|
// ✅ Matches both model_number and location.
|
|
Asset::factory()->create([
|
|
'asset_tag' => 'AND-MATCH-1',
|
|
'model_id' => $modelMatch->id,
|
|
'location_id' => $locationA->id,
|
|
]);
|
|
|
|
// ❌ Matches location only.
|
|
Asset::factory()->create([
|
|
'asset_tag' => 'AND-LOC-ONLY',
|
|
'model_id' => $modelOther->id,
|
|
'location_id' => $locationA->id,
|
|
]);
|
|
|
|
// ❌ Matches model_number only.
|
|
Asset::factory()->create([
|
|
'asset_tag' => 'AND-MODEL-ONLY',
|
|
'model_id' => $modelMatch->id,
|
|
'location_id' => $locationB->id,
|
|
]);
|
|
|
|
$this->actingAsForApi(User::factory()->viewAssets()->create())
|
|
->getJson(route('api.assets.index', [
|
|
'filter' => json_encode([
|
|
'model_number' => 'MODEL-111',
|
|
'location' => 'HQ-East',
|
|
]),
|
|
'filter_operator' => 'and',
|
|
]))
|
|
->assertOk()
|
|
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
|
|
}
|
|
}
|