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()); } }