Files
snipe-it/tests/Feature/Search/SearchableTraitTest.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());
}
}