Final fixes, tests

This commit is contained in:
snipe
2026-04-03 11:08:16 +01:00
parent 70a30a96fa
commit d929c87bbd
4 changed files with 945 additions and 36 deletions
+14
View File
@@ -217,6 +217,20 @@ class Asset extends Depreciable
'assigned_to' => ['name'],
];
/**
* Maps the field names exposed by the API / transformers to the actual
* Eloquent relation names used in $searchableRelations.
*
* This lets callers filter using the same key they see in API responses
* without needing to know the internal relation name.
*
* @var array<string, string> [ api_key => relation_name ]
*/
protected $searchableRelationAliases = [
'status_label' => 'assetstatus',
'assigned_to' => 'assignedTo',
];
protected static function booted(): void
{
static::forceDeleted(function (Asset $asset) {
+290 -36
View File
@@ -16,7 +16,7 @@ use Illuminate\Support\Facades\DB;
* This handles all the out of the box advanced search stuff (using the "advanced search" bootstrap table plugin),
* allowing you to just define which attributes and relations should be searched, and then it does the rest.
*
* You can override these trait methods (for example, advancedSearch) if you need different ebhavior, but this really
* You can override these trait methods (for example, advancedSearch) if you need different behavior, but this really
* should cover most of the use cases, and allows you to easily add searching to your models without having to
* write complex queries.
*
@@ -29,13 +29,21 @@ use Illuminate\Support\Facades\DB;
* if ($request->filled('filter') || $request->filled('search')) {
* $whateverModel->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
* }
* 4. Set the "data-advanced
* 4. Set the "data-advanced-search="true" in the
*
*
* @author Till Deeke <kontakt@tilldeeke.de>
*/
trait Searchable
{
/**
* Per-class cache for the custom field filter map, keyed by db_column / lowercase name.
* Populated lazily; cleared via flushCustomFieldFilterMap().
*
* @var array<string, string>|null
*/
private static ?array $customFieldFilterMapCache = null;
/**
* Performs a search on the model, using the provided search terms
*
@@ -151,6 +159,8 @@ trait Searchable
/**
* Prepares the search term, splitting and cleaning it up
*
* @TODO: see if there's a way to tweak the advanced search plugin to split the terms on the frontend, so we don't have to do it here. This is pretty hacky and fragile, since it relies on the user inputting " OR " between search terms, which is not very user-friendly, but we could potentially hack the advanced search extension itself to add an operator. (That extension's UI is pretty terrible, but it's what we have)
*
* @param string $search The search term
* @return array An array of search terms
*/
@@ -184,6 +194,19 @@ trait Searchable
continue;
}
// Check if this is a custom field (only for Assets - for *now*).
// Accepts both the human-readable field name (e.g. "CPU") and the raw
// db_column slug (e.g. "_snipeit_cpu_4") as filter keys.
if ($this instanceof Asset) {
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if ($dbColumn !== null) {
$query->where($table . '.' . $dbColumn, 'LIKE', '%' . $filterValue . '%');
continue;
}
}
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
if ($resolvedRelationKey === null) {
@@ -233,18 +256,36 @@ trait Searchable
/**
* Resolve alias keys to configured searchable relation keys.
*
* Resolution order:
* 1. Direct match in $searchableRelations (relation name used as-is by the API)
* 2. $searchableRelationAliases (API/transformer key → Eloquent relation name)
* 3. Built-in assigned_to ↔ assignedTo camel/snake alias
*/
private function resolveSearchableRelationKey(string $filterKey, array $searchableRelations): ?string
{
// 1. Direct match — the filter key is already the relation name.
if (array_key_exists($filterKey, $searchableRelations)) {
return $filterKey;
}
if (($filterKey === 'assigned_to') && array_key_exists('assignedTo', $searchableRelations)) {
// 2. Model-defined aliases — e.g. 'status_label' => 'assetstatus'.
$aliases = $this->getSearchableRelationAliases();
if (array_key_exists($filterKey, $aliases)) {
$aliasedRelation = $aliases[$filterKey];
if (array_key_exists($aliasedRelation, $searchableRelations)) {
return $aliasedRelation;
}
}
// 3. Built-in camel/snake alias for the polymorphic assignee relation.
if ($filterKey === 'assigned_to' && array_key_exists('assignedTo', $searchableRelations)) {
return 'assignedTo';
}
if (($filterKey === 'assignedTo') && array_key_exists('assigned_to', $searchableRelations)) {
if ($filterKey === 'assignedTo' && array_key_exists('assigned_to', $searchableRelations)) {
return 'assigned_to';
}
@@ -264,11 +305,9 @@ trait Searchable
*/
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder
{
$relationName = ($relationKey === 'assigned_to' && method_exists($this, 'assignedTo'))
? 'assignedTo'
: $relationKey;
$relationName = $this->resolveAssignedToRelationName();
if (! method_exists($this, $relationName)) {
if ($relationName === null) {
return $query;
}
@@ -276,29 +315,24 @@ trait Searchable
$relationName,
[User::class, Asset::class, Location::class],
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) {
$assigneeColumns = match ($assigneeType) {
User::class => ['first_name', 'last_name', 'username', 'display_name'],
Asset::class => ['asset_tag', 'name'],
Location::class => ['name'],
default => [],
};
$columns = $this->getAssigneeColumnsByType($assigneeType);
if (empty($assigneeColumns)) {
if (empty($columns)) {
return;
}
$assigneeTable = (new $assigneeType)->getTable();
$table = (new $assigneeType)->getTable();
$firstConditionAdded = false;
foreach ($assigneeColumns as $assigneeColumn) {
foreach ($columns as $column) {
if (! $firstConditionAdded) {
$assigneeQuery->where($assigneeTable.'.'.$assigneeColumn, 'LIKE', '%'.$filterValue.'%');
$assigneeQuery->where($table . '.' . $column, 'LIKE', '%' . $filterValue . '%');
$firstConditionAdded = true;
continue;
}
$assigneeQuery->orWhere($assigneeTable.'.'.$assigneeColumn, 'LIKE', '%'.$filterValue.'%');
$assigneeQuery->orWhere($table . '.' . $column, 'LIKE', '%' . $filterValue . '%');
}
if ($assigneeType === User::class) {
@@ -311,6 +345,42 @@ trait Searchable
);
}
/**
* Get the searchable columns for a given assignee morph type.
*
* Users have no "name" column, only first_name/last_name/username/display_name.
* Assets use asset_tag as the primary identifier (name is nullable).
* Locations use name.
*/
private function getAssigneeColumnsByType(string $assigneeType): array
{
return match ($assigneeType) {
User::class => ['first_name', 'last_name', 'username', 'display_name'],
Asset::class => ['asset_tag', 'name'],
Location::class => ['name'],
default => [],
};
}
/**
* Resolve the actual relation method name for the assignedTo polymorphic relation.
*
* Models may define it as "assignedTo" (camelCase) or "assigned_to" (snake_case).
* We prefer "assignedTo" when both exist.
*/
private function resolveAssignedToRelationName(): ?string
{
if (method_exists($this, 'assignedTo')) {
return 'assignedTo';
}
if (method_exists($this, 'assigned_to')) {
return 'assigned_to';
}
return null;
}
/**
* Apply filtering on computed count aliases (for example withCount aliases).
*/
@@ -386,10 +456,18 @@ trait Searchable
}
$customFields = CustomField::all();
$firstConditionAdded = false;
foreach ($customFields as $field) {
foreach ($terms as $term) {
$query->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
if (!$firstConditionAdded) {
$query = $query->where($this->getTable() . '.' . $field->db_column_name(), 'LIKE', '%' . $term . '%');
$firstConditionAdded = true;
continue;
}
$query = $query->orWhere($this->getTable() . '.' . $field->db_column_name(), 'LIKE', '%' . $term . '%');
}
}
@@ -401,13 +479,33 @@ trait Searchable
*
* @param $query Builder
* @param $terms array
* @return Builder
*/
private function searchRelations(Builder $query, array $terms)
private function searchRelations(Builder $query, array $terms): Builder
{
foreach ($this->getSearchableRelations() as $relation => $columns) {
// Polymorphic assignee relations need special per-type column handling
// because users, assets, and locations each have different identifier columns.
if ($this->isAssignedToRelationKey($relation)) {
$query = $this->searchAssignedToRelation($query, $terms);
continue;
}
$isUserRelation = in_array($relation, ['adminuser', 'user'], true);
// Pre-build the concat SQL outside the closure so $this->buildMultipleColumnSearch()
// doesn't need to be called inside a nested closure context.
$concatSql = $isUserRelation
? $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name'])
: null;
$query = $query->orWhereHas(
$relation, function ($query) use ($relation, $columns, $terms) {
$relation, function (Builder $relationQuery) use ($relation, $columns, $terms, $isUserRelation, $concatSql) {
// $table must be resolved inside the closure for self-referential relations
// (e.g. User->manager, User->adminuser). getRelationTable relies on the
// alias counter that orWhereHas increments before this callback runs.
$table = $this->getRelationTable($relation);
/**
@@ -421,26 +519,22 @@ trait Searchable
foreach ($columns as $column) {
foreach ($terms as $term) {
if (! $firstConditionAdded) {
$query->where($table.'.'.$column, 'LIKE', '%'.$term.'%');
$relationQuery->where($table . '.' . $column, 'LIKE', '%' . $term . '%');
$firstConditionAdded = true;
continue;
}
$query->orWhere($table.'.'.$column, 'LIKE', '%'.$term.'%');
$relationQuery->orWhere($table . '.' . $column, 'LIKE', '%' . $term . '%');
}
}
// I put this here because I only want to add the concat one time in the end of the user relation search
if (($relation == 'adminuser') || ($relation == 'user')) {
$query->orWhereRaw(
$this->buildMultipleColumnSearch(
[
'users.first_name',
'users.last_name',
]
),
["%{$term}%"]
);
// Also search first+last name concatenated for user relations so that
// "John Smith" matches even when the terms are split across columns.
if ($isUserRelation && $concatSql !== null) {
foreach ($terms as $term) {
$relationQuery->orWhereRaw($concatSql, ["%{$term}%"]);
}
}
}
);
@@ -449,6 +543,62 @@ trait Searchable
return $query;
}
/**
* Search across the polymorphic assignee relation (assignedTo / assigned_to).
*
* Uses whereHasMorph so that each possible assignee type is constrained to the
* columns that actually exist on that type:
* - User → first_name, last_name, username, display_name
* - Asset → asset_tag, name
* - Location → name
*/
private function searchAssignedToRelation(Builder $query, array $terms): Builder
{
$relationName = $this->resolveAssignedToRelationName();
if ($relationName === null) {
return $query;
}
return $query->orWhereHasMorph(
$relationName,
[User::class, Asset::class, Location::class],
function (Builder $morphQuery, string $morphType) use ($terms) {
$columns = $this->getAssigneeColumnsByType($morphType);
if (empty($columns)) {
return;
}
$table = (new $morphType)->getTable();
$firstConditionAdded = false;
foreach ($columns as $column) {
foreach ($terms as $term) {
if (!$firstConditionAdded) {
$morphQuery->where($table . '.' . $column, 'LIKE', '%' . $term . '%');
$firstConditionAdded = true;
continue;
}
$morphQuery->orWhere($table . '.' . $column, 'LIKE', '%' . $term . '%');
}
}
// Also search first+last concatenated for users.
if ($morphType === User::class) {
foreach ($terms as $term) {
$morphQuery->orWhereRaw(
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
["%{$term}%"]
);
}
}
}
);
}
/**
* Run additional, advanced searches that can't be done using the attributes or relations.
*
@@ -493,6 +643,25 @@ trait Searchable
return $this->searchableCounts ?? [];
}
/**
* Get the relation aliases defined on the model.
*
* Maps the field names that the API / transformers expose to the actual
* Eloquent relation names used in $searchableRelations. For example:
*
* protected $searchableRelationAliases = [
* 'status_label' => 'assetstatus',
* ];
*
* Override this method in a model if you need dynamic alias resolution.
*
* @return array<string, string> [ api_key => relation_name ]
*/
protected function getSearchableRelationAliases(): array
{
return $this->searchableRelationAliases ?? [];
}
/**
* Get the table name of a relation.
*
@@ -573,4 +742,89 @@ trait Searchable
{
return $query->orWhereRaw($this->buildMultipleColumnSearch($columns), ["%{$term}%"]);
}
/**
* Resolve a filter key to the actual database column name for a custom field.
*
* Accepts both human-readable field names (e.g. "CPU", "cpu") and raw
* db_column slugs (e.g. "_snipeit_cpu_4") as filter keys.
*
* Returns null when the key cannot be matched to any known custom field.
*
* Only applicable to the Asset model.
*/
private function resolveCustomFieldDbColumn(string $filterKey): ?string
{
if (!$this instanceof Asset) {
return null;
}
$map = $this->buildCustomFieldFilterMap();
// 1. Exact match on db_column (e.g. "_snipeit_cpu_4")
if (array_key_exists($filterKey, $map)) {
return $map[$filterKey];
}
// 2. Case-insensitive match on human-readable field name (e.g. "CPU", "cpu")
$lowerKey = strtolower($filterKey);
if (array_key_exists($lowerKey, $map)) {
return $map[$lowerKey];
}
return null;
}
/**
* Build a lookup map for custom field filter resolution.
*
* The returned array has two types of entries for every custom field:
* - db_column (exact) → db_column e.g. "_snipeit_cpu_4" => "_snipeit_cpu_4"
* - lowercase name → db_column e.g. "cpu" => "_snipeit_cpu_4"
*
* Results are cached statically for the duration of the request.
* Call flushCustomFieldFilterMap() to reset the cache (useful in tests).
*
* @return array<string, string>
*/
private function buildCustomFieldFilterMap(): array
{
if (isset(static::$customFieldFilterMapCache)) {
return static::$customFieldFilterMapCache;
}
$map = [];
try {
CustomField::query()
->whereNotNull('db_column')
->get(['name', 'db_column'])
->each(function (CustomField $field) use (&$map): void {
$dbColumn = $field->db_column;
// Exact db_column key (e.g. "_snipeit_cpu_4")
$map[$dbColumn] = $dbColumn;
// Lowercase human-readable name key (e.g. "cpu")
$map[strtolower($field->name)] = $dbColumn;
});
} catch (\Exception $e) {
// Guard against missing table or schema issues during migrations / tests
}
static::$customFieldFilterMapCache = $map;
return $map;
}
/**
* Flush the custom field filter map cache.
*
* Useful in tests or after custom fields are added/modified.
*/
public static function flushCustomFieldFilterMap(): void
{
static::$customFieldFilterMapCache = null;
}
}
@@ -0,0 +1,634 @@
<?php
namespace Tests\Feature\Search;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\CustomField;
use App\Models\CustomFieldset;
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 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 → assetstatus) 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 → assetstatus)
*/
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 'assetstatus' 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());
}
/**
* 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());
}
/**
* 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 filtering on a custom field by its human-readable name (e.g. "CPU").
*
* The filter key should be case-insensitive and should map to the underlying
* db_column (e.g. "_snipeit_cpu_N") automatically.
*/
public function test_custom_field_filter_by_human_readable_name()
{
$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 human-readable field name "CPU" (exact case)
$this->actingAsForApi(User::factory()->viewAssets()->create())
->getJson(route('api.assets.index', [
'filter' => json_encode(['CPU' => '3.2GHz']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
}
/**
* Test filtering on a custom field using a lowercase name.
*/
public function test_custom_field_filter_by_lowercase_name()
{
$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()
->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_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::flushCustomFieldFilterMap();
$this->actingAsForApi(User::factory()->viewAssets()->create())
->getJson(route('api.assets.index', [
'filter' => json_encode([$dbColumn => 'i9']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->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(['CPU' => 'NonExistentCPU']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
}
/**
* 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(['CPU' => 'Intel']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
}
}
+7
View File
@@ -3,6 +3,7 @@
namespace Tests;
use App\Http\Middleware\SecurityHeaders;
use App\Models\Asset;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use RuntimeException;
@@ -39,8 +40,14 @@ abstract class TestCase extends BaseTestCase
$this->withoutMiddleware($this->globallyDisabledMiddleware);
$this->initializeSettings();
// Flush the custom field filter map cache between tests so that
// dynamically-created custom fields are always picked up fresh.
Asset::flushCustomFieldFilterMap();
}
// ...existing code...
private function guardAgainstMissingEnv(): void
{
if (! file_exists(realpath(__DIR__.'/../').'/.env.testing')) {