Files
opensourcepos/tests/Models/ItemSearchTest.php
objecttothis 1932251a73 fix(items): enhance named attribute search with improved type handling
- Add support for searching named attributes with decimal and date types.
- Extend parsing logic to handle locale-specific decimal separators.
- Update `applyNamedAttributeSearch` to return consumed names for better compatibility with free-text search.
- Refine regex matching for combined attribute search syntax.
- Expand test coverage for named attribute search across various scenarios.

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-08-21 15:41:47 +04:00

469 lines
18 KiB
PHP

<?php
namespace Tests\Models;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Item;
use Config\Database;
use Config\OSPOS;
use Tests\Support\ItemSearchFixtureTrait;
class ItemSearchTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use ItemSearchFixtureTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $seed = '';
protected $seedOnce = true;
protected $refresh = true;
protected $namespace = null;
protected $item;
public static function setUpBeforeClass(): void
{
$seeder = Database::seeder('tests');
$seeder->call('TestDatabaseBootstrapSeeder');
}
protected function setUp(): void
{
parent::setUp();
$this->item = model(Item::class);
// OSPOS's app_config cache can go stale mid-suite and fall back to defaults with
// no 'dateformat' key, so set it directly rather than depending on that cache.
config(OSPOS::class)->settings['dateformat'] = 'm/d/Y';
config(OSPOS::class)->settings['number_locale'] = 'en_US';
config(OSPOS::class)->settings['currency_decimals'] = 2;
}
protected function tearDown(): void
{
parent::tearDown();
}
public function testSearchReturnsMatchingItemByName(): void
{
$uniqueName = 'Findable Widget ' . uniqid();
$this->createSearchableItem([
'name' => $uniqueName,
'cost_price' => 12.50,
'unit_price' => 24.99,
]);
$results = $this->item->search($uniqueName, $this->defaultSearchFilters())->getResult();
$this->assertCount(1, $results);
$this->assertEquals($uniqueName, $results[0]->name);
$this->assertEquals(12.50, (float) $results[0]->cost_price);
$this->assertEquals(24.99, (float) $results[0]->unit_price);
}
public function testSearchExcludesNonMatchingItems(): void
{
$matchingName = 'Matching Gadget ' . uniqid();
$this->createSearchableItem(['name' => $matchingName]);
$this->createSearchableItem(['name' => 'Unrelated Thing ' . uniqid()]);
$results = $this->item->search($matchingName, $this->defaultSearchFilters())->getResult();
$this->assertCount(1, $results);
$this->assertEquals($matchingName, $results[0]->name);
}
public function testGetFoundRowsMatchesActualCount(): void
{
$sharedCategory = 'Shared Category ' . uniqid();
$this->createSearchableItem(['category' => $sharedCategory]);
$this->createSearchableItem(['category' => $sharedCategory]);
$this->createSearchableItem(['category' => $sharedCategory]);
$filters = $this->defaultSearchFilters();
$foundRows = $this->item->get_found_rows($sharedCategory, $filters);
$actualRows = $this->item->search($sharedCategory, $filters)->getResultArray();
$this->assertEquals(3, $foundRows);
$this->assertCount($foundRows, $actualRows);
}
public function testSearchPaginationRowsAndLimitFrom(): void
{
$sharedCategory = 'Paginated Category ' . uniqid();
$this->createSearchableItem(['name' => 'A Item ' . uniqid(), 'category' => $sharedCategory]);
$this->createSearchableItem(['name' => 'B Item ' . uniqid(), 'category' => $sharedCategory]);
$this->createSearchableItem(['name' => 'C Item ' . uniqid(), 'category' => $sharedCategory]);
$results = $this->item->search(
$sharedCategory,
$this->defaultSearchFilters(),
1,
1,
'items.name',
'asc'
)->getResult();
$this->assertCount(1, $results);
$this->assertStringStartsWith('B Item', $results[0]->name);
}
public function testSearchOrderPreservedAfterPhaseBJoin(): void
{
$sharedCategory = 'Ordered Category ' . uniqid();
$this->createSearchableItem(['name' => 'Alpha Item ' . uniqid(), 'category' => $sharedCategory]);
$this->createSearchableItem(['name' => 'Beta Item ' . uniqid(), 'category' => $sharedCategory]);
$this->createSearchableItem(['name' => 'Gamma Item ' . uniqid(), 'category' => $sharedCategory]);
$results = $this->item->search(
$sharedCategory,
$this->defaultSearchFilters(),
0,
0,
'items.name',
'desc'
)->getResult();
$names = array_map(static fn ($item) => $item->name, $results);
$sorted = $names;
rsort($sorted);
$this->assertSame($sorted, $names);
}
public function testSearchNoMatchesReturnsEmptyResultNotError(): void
{
$results = $this->item->search('no-such-item-' . uniqid(), $this->defaultSearchFilters())->getResult();
$this->assertIsArray($results);
$this->assertCount(0, $results);
}
public function testSearchExcludesDeletedItemsByDefault(): void
{
$deletedName = 'Deleted Item ' . uniqid();
$this->createSearchableItem(['name' => $deletedName, 'deleted' => 1]);
$results = $this->item->search($deletedName, $this->defaultSearchFilters(['is_deleted' => false]))->getResult();
$this->assertCount(0, $results);
}
public function testSearchIncludesDeletedWhenFilterSet(): void
{
$deletedName = 'Deleted Item ' . uniqid();
$this->createSearchableItem(['name' => $deletedName, 'deleted' => 1]);
$results = $this->item->search($deletedName, $this->defaultSearchFilters(['is_deleted' => true]))->getResult();
$this->assertCount(1, $results);
$this->assertEquals($deletedName, $results[0]->name);
}
public function testSearchStockLocationFilter(): void
{
$locationAName = 'Location A Item ' . uniqid();
$itemId = $this->createSearchableItem(['name' => $locationAName]);
$this->addItemQuantity($itemId, 1, 10);
$resultsAtLocation1 = $this->item->search(
$locationAName,
$this->defaultSearchFilters(['stock_location_id' => 1])
)->getResult();
$resultsAtLocation2 = $this->item->search(
$locationAName,
$this->defaultSearchFilters(['stock_location_id' => 2])
)->getResult();
$this->assertCount(1, $resultsAtLocation1);
$this->assertCount(0, $resultsAtLocation2);
}
public function testSearchTemporaryFilter(): void
{
$tempName = 'Temp Item ' . uniqid();
$this->createSearchableItem(['name' => $tempName, 'item_type' => ITEM_TEMP]);
$resultsWhenTemporary = $this->item->search(
$tempName,
$this->defaultSearchFilters(['temporary' => true])
)->getResult();
$resultsWhenNotTemporary = $this->item->search(
$tempName,
$this->defaultSearchFilters(['temporary' => false])
)->getResult();
$this->assertCount(1, $resultsWhenTemporary);
$this->assertCount(0, $resultsWhenNotTemporary);
}
public function testSearchSortByQuantitySumsAcrossLocationsWithoutMultiplication(): void
{
$itemId = $this->createSearchableItem(['name' => 'Multi Location Item ' . uniqid()]);
// createSearchableItem already inserts one inventory row; add two more
// inventory transactions so a naive SUM-after-join (which multiplies
// item_quantities rows by the inventory join) would triple-count.
$this->addInventoryRecord($itemId, 1);
$this->addInventoryRecord($itemId, 1);
$this->addItemQuantity($itemId, 1, 10);
$this->addItemQuantity($itemId, 2, 5);
$results = $this->item->search(
'',
$this->defaultSearchFilters(),
0,
0,
'quantity',
'desc'
)->getResult();
$match = array_values(array_filter($results, static fn ($r) => (int) $r->item_id === $itemId));
$this->assertCount(1, $match);
$this->assertEquals(15, (float) $match[0]->quantity);
}
public function testSearchByNamedAttributeSyntax(): void
{
$colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid());
$sizeDefinitionId = $this->createAttributeDefinition('Size ' . uniqid());
$blueValue = 'Blue' . uniqid();
$redValue = 'Red' . uniqid();
$largeValue = 'Large' . uniqid();
$matchingId = $this->createSearchableItem(['name' => 'Named Attr Match ' . uniqid()]);
$this->linkAttributeValue($matchingId, $colorDefinitionId, $blueValue);
$this->linkAttributeValue($matchingId, $sizeDefinitionId, $largeValue);
$wrongColorId = $this->createSearchableItem(['name' => 'Named Attr WrongColor ' . uniqid()]);
$this->linkAttributeValue($wrongColorId, $colorDefinitionId, $redValue);
$this->linkAttributeValue($wrongColorId, $sizeDefinitionId, $largeValue);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$colorDefinitionId, $sizeDefinitionId],
]);
$colorAttrName = $this->getDefinitionName($colorDefinitionId);
$sizeAttrName = $this->getDefinitionName($sizeDefinitionId);
$results = $this->item->search("{$colorAttrName}:{$blueValue} {$sizeAttrName}:{$largeValue}", $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
$this->assertNotContains($wrongColorId, $ids);
}
public function testSearchByNamedAttributeSyntaxWithCommaSeparator(): void
{
// parseAttributeSearch() treats a bare comma between name:value pairs as an implicit
// separator (like AND/OR), so "color:blue, size:large" must parse both attributes.
$colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid());
$sizeDefinitionId = $this->createAttributeDefinition('Size ' . uniqid());
$blueValue = 'Blue' . uniqid();
$largeValue = 'Large' . uniqid();
$matchingId = $this->createSearchableItem(['name' => 'Comma Sep Match ' . uniqid()]);
$this->linkAttributeValue($matchingId, $colorDefinitionId, $blueValue);
$this->linkAttributeValue($matchingId, $sizeDefinitionId, $largeValue);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$colorDefinitionId, $sizeDefinitionId],
]);
$colorAttrName = $this->getDefinitionName($colorDefinitionId);
$sizeAttrName = $this->getDefinitionName($sizeDefinitionId);
$results = $this->item->search("{$colorAttrName}:{$blueValue}, {$sizeAttrName}:{$largeValue}", $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
}
public function testSearchByNamedAttributeCombinedWithFreeText(): void
{
// When search_custom is on, the free-text remainder matches attribute values
// (not item name/category/etc) - see the customAttributeSearch branch of search().
// An item can only have one attribute_links row per definition_id (attribute_links_uq3),
// so the name:value attribute and the free-text-matched attribute must be different
// definitions.
$colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid());
$materialDefinitionId = $this->createAttributeDefinition('Material ' . uniqid());
$greenValue = 'Green' . uniqid();
$freeTextValue = 'Cotton' . uniqid();
$matchingId = $this->createSearchableItem(['name' => 'Combo Search Widget ' . uniqid()]);
$this->linkAttributeValue($matchingId, $colorDefinitionId, $greenValue);
$this->linkAttributeValue($matchingId, $materialDefinitionId, $freeTextValue);
$wrongFreeTextId = $this->createSearchableItem(['name' => 'Combo Search Other ' . uniqid()]);
$this->linkAttributeValue($wrongFreeTextId, $colorDefinitionId, $greenValue);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$colorDefinitionId, $materialDefinitionId],
]);
$colorAttrName = $this->getDefinitionName($colorDefinitionId);
$results = $this->item->search("{$colorAttrName}:{$greenValue} {$freeTextValue}", $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
$this->assertNotContains($wrongFreeTextId, $ids);
}
public function testPlainFreeTextSearchUnaffectedByAttributeParsing(): void
{
// search_custom is off here, so this exercises the plain by-name search path -
// parseAttributeSearch() is never even called in this branch of search().
$uniqueName = 'Plain Search Widget ' . uniqid();
$this->createSearchableItem(['name' => $uniqueName]);
$results = $this->item->search($uniqueName, $this->defaultSearchFilters())->getResult();
$this->assertCount(1, $results);
$this->assertEquals($uniqueName, $results[0]->name);
}
public function testSearchCustomWithoutNamedAttributeSyntaxMatchesAttributeValueAsBefore(): void
{
// No "name:value" syntax present, so parseAttributeSearch() finds no attributes and
// $freeTextSearch falls back to the raw $search - this is the pre-Feature-C behavior
// for search_custom, unaffected by the new named-attribute join.
$definitionId = $this->createAttributeDefinition('Material ' . uniqid());
$searchValue = 'Cotton' . uniqid();
$matchingId = $this->createSearchableItem(['name' => 'Search Custom Widget ' . uniqid()]);
$this->linkAttributeValue($matchingId, $definitionId, $searchValue);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$definitionId],
]);
$results = $this->item->search($searchValue, $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
}
public function testSearchByNamedAttributeMatchesDecimalAttribute(): void
{
$priceDefinitionId = $this->createAttributeDefinition('Price ' . uniqid(), DECIMAL);
$matchingId = $this->createSearchableItem(['name' => 'Decimal Attr Match ' . uniqid()]);
$this->linkTypedAttributeValue($matchingId, $priceDefinitionId, '19.99', DECIMAL);
$otherId = $this->createSearchableItem(['name' => 'Decimal Attr Other ' . uniqid()]);
$this->linkTypedAttributeValue($otherId, $priceDefinitionId, '5.00', DECIMAL);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$priceDefinitionId],
]);
$priceAttrName = $this->getDefinitionName($priceDefinitionId);
$results = $this->item->search("{$priceAttrName}:19.99", $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
$this->assertNotContains($otherId, $ids);
}
public function testSearchByNamedAttributeMatchesDateAttribute(): void
{
config(OSPOS::class)->settings['dateformat'] = 'm/d/Y';
$expiryDefinitionId = $this->createAttributeDefinition('Expiry ' . uniqid(), DATE);
$matchingId = $this->createSearchableItem(['name' => 'Date Attr Match ' . uniqid()]);
$this->linkTypedAttributeValue($matchingId, $expiryDefinitionId, '01/15/2027', DATE);
$otherId = $this->createSearchableItem(['name' => 'Date Attr Other ' . uniqid()]);
$this->linkTypedAttributeValue($otherId, $expiryDefinitionId, '02/20/2027', DATE);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$expiryDefinitionId],
]);
$expiryAttrName = $this->getDefinitionName($expiryDefinitionId);
$results = $this->item->search("{$expiryAttrName}:01/15/2027", $filters)->getResult();
$ids = array_map(static fn ($item) => (int) $item->item_id, $results);
$this->assertContains($matchingId, $ids);
$this->assertNotContains($otherId, $ids);
}
public function testSearchByNamedAttributeDecimalRespectsLocaleDecimalSeparator(): void
{
$config = config(OSPOS::class);
$originalLocale = $config->settings['number_locale'];
$originalDecimals = $config->settings['currency_decimals'];
$config->settings['number_locale'] = 'de_DE';
$config->settings['currency_decimals'] = 2;
try {
$priceDefinitionId = $this->createAttributeDefinition('LocalePrice ' . uniqid(), DECIMAL);
$matchingId = $this->createSearchableItem(['name' => 'Locale Decimal Match ' . uniqid()]);
$this->linkTypedAttributeValue($matchingId, $priceDefinitionId, '19.99', DECIMAL);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$priceDefinitionId],
]);
$priceAttrName = $this->getDefinitionName($priceDefinitionId);
// Comma decimal separator, matching the de_DE locale, should parse and match.
$commaResults = $this->item->search("{$priceAttrName}:19,99", $filters)->getResult();
$commaIds = array_map(static fn ($item) => (int) $item->item_id, $commaResults);
$this->assertContains($matchingId, $commaIds);
} finally {
$config->settings['number_locale'] = $originalLocale;
$config->settings['currency_decimals'] = $originalDecimals;
}
}
public function testSearchByUnknownNamedAttributeDoesNotReturnAllItems(): void
{
$definitionId = $this->createAttributeDefinition('Known ' . uniqid());
$knownAttrName = $this->getDefinitionName($definitionId);
$this->createSearchableItem(['name' => 'Unrelated Item ' . uniqid()]);
$this->createSearchableItem(['name' => 'Another Unrelated Item ' . uniqid()]);
$filters = $this->defaultSearchFilters([
'search_custom' => true,
'definition_ids' => [$definitionId],
]);
$results = $this->item->search("{$knownAttrName}:nomatch nosuchattr:{$knownAttrName}", $filters)->getResult();
$this->assertCount(0, $results);
}
private function getDefinitionName(int $definitionId): string
{
return db_connect()->table('attribute_definitions')
->select('definition_name')
->where('definition_id', $definitionId)
->get()
->getRow()
->definition_name;
}
}